diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml deleted file mode 100644 index f76e929..0000000 --- a/.github/workflows/security-scan.yml +++ /dev/null @@ -1,155 +0,0 @@ -name: Security Scan - -on: - pull_request_target: - types: [opened, synchronize, reopened] - branches: [main] - -permissions: - contents: read - pull-requests: write - issues: write - checks: write - security-events: write - statuses: write - -jobs: - security-scan: - runs-on: ubuntu-latest - - steps: - - name: Checkout PR - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Cache pip packages - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install bandit safety - - - name: Run Security Scan - id: security_scan - env: - BANDIT_SKIP_IDS: ${{ secrets.BANDIT_SKIP_IDS }} - run: | - # Run bandit recursively on all Python files - echo "Running Bandit security scan..." - - bandit -r . \ - --severity-level medium \ - --skip "${BANDIT_SKIP_IDS}" \ - -f txt \ - -x .venv,venv,site-packages,build,dist,.tox,tests,migrations \ - -o bandit-results.txt || true - - # Run Safety check on requirements - if [ -f "requirements.txt" ]; then - echo "Checking dependencies with Safety..." - safety scan -r requirements.txt --output text > safety-results.txt || true - fi - - # Combine results - echo "πŸ”’ Security Scan Results" > security-scan-results.txt - echo "=========================" >> security-scan-results.txt - echo "" >> security-scan-results.txt - - if [ -f "bandit-results.txt" ]; then - echo "Bandit Scan Results:" >> security-scan-results.txt - echo "-------------------" >> security-scan-results.txt - cat bandit-results.txt >> security-scan-results.txt - echo "" >> security-scan-results.txt - fi - - if [ -f "safety-results.txt" ]; then - echo "Dependency Check Results:" >> security-scan-results.txt - echo "-----------------------" >> security-scan-results.txt - cat safety-results.txt >> security-scan-results.txt - fi - - # Check for critical issues - if grep -iE "Severity\:\ High|Severity\:\ Critical" bandit-results.txt > /dev/null 2>&1; then - echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT - elif [ -f "safety-results.txt" ] && grep -iE "critical" safety-results.txt > /dev/null 2>&1; then - echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT - else - echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT - fi - - - name: Create comment body - id: create-comment - if: always() - run: | - if [ -f security-scan-results.txt ]; then - SCAN_RESULTS=$(cat security-scan-results.txt) - if [ "${{ steps.security_scan.outputs.vulnerabilities_found }}" == "true" ]; then - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ Security Scan Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo "$SCAN_RESULTS" >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo '⛔️ **Critical vulnerabilities detected. Please review and address these security issues before merging.**' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo '### Next Steps:' >> $GITHUB_ENV - echo '1. Review each critical finding above and fix them according to OWASP top 10 mitigations.' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - else - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ Security Scan Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo "$SCAN_RESULTS" >> $GITHUB_ENV - echo '```' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'βœ… **No critical security issues detected.**' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo 'The code has passed all critical security checks.' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - fi - else - echo 'comment_body<> $GITHUB_ENV - echo '## πŸ”’ Security Scan Results' >> $GITHUB_ENV - echo '' >> $GITHUB_ENV - echo '⚠️ **Error: The security scan failed to complete. Please review the workflow logs for more information.**' >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - fi - - - name: Comment PR - uses: peter-evans/create-or-update-comment@v4 - if: always() - with: - issue-number: ${{ github.event.pull_request.number }} - body: ${{ env.comment_body }} - - - name: Upload scan artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: security-scan-results - path: | - security-scan-results.txt - bandit-results.txt - safety-results.txt - retention-days: 5 - - - name: Fail if vulnerabilities found - if: steps.security_scan.outputs.vulnerabilities_found == 'true' - run: | - echo "::error::Critical security vulnerabilities were detected. Please review the findings and address them before merging." - exit 1 diff --git a/TECHNICAL_DOCUMENTATION_v1.md b/TECHNICAL_DOCUMENTATION_v1.md new file mode 100644 index 0000000..bd75857 --- /dev/null +++ b/TECHNICAL_DOCUMENTATION_v1.md @@ -0,0 +1,1714 @@ +# Rebback ooperation Garmin App - Technical Documentation + +## Table of Contents +0. [prequisites] (#prequisites) +0.5 [Build Process](#Build-Process) +1. [Architecture Overview](#architecture-overview) +2. [Core Components](#core-components) +3. [Data Flow](#data-flow) +4. [State Management](#state-management) +5. [Activity Recording System](#activity-recording-system) +6. [Cadence Quality Algorithm](#cadence-quality-algorithm) +7. [User Interface](#user-interface) +8. [Settings System](#settings-system) +9. [Features Reference](#features-reference) + +--- +## prequisites +- Garmin Connect IQ SDK 8.3.0+ +- Visual Studio Code with Connect IQ extension +- Forerunner 165/165 Music device or simulator + +## Build-Process +1. Clone repository +2. Configure project settings in `monkey.jungle` +3. Build for target device: + ```bash + monkeyc -o bin/app.prg -f monkey.jungle -y developer_key.der + +## Architecture Overview + +### Application Type +- **Type**: Garmin Watch App (not data field or widget) +- **Target Devices**: Forerunner 165, Forerunner 165 Music +- **SDK Version**: Minimum API Level 5.2.0 +- **Architecture**: MVC (Model-View-Controller/Delegate pattern) + +### High-Level Structure +``` +GarminApp (Application Core) + β”œβ”€β”€ Views + β”‚ β”œβ”€β”€ SimpleView (Main activity view) + β”‚ └── AdvancedView (Chart visualization) + β”œβ”€β”€ Delegates (Input handlers) + β”‚ β”œβ”€β”€ SimpleViewDelegate (Main controls) + β”‚ β”œβ”€β”€ AdvancedViewDelegate (Chart controls) + β”‚ └── Settings Delegates (Configuration) + β”œβ”€β”€ Managers + β”‚ β”œβ”€β”€ SensorManager (Cadence sensor) + β”‚ └── Logger (Memory tracking) + └── Data Processing + β”œβ”€β”€ Cadence Quality Calculator + └── Activity Recording Session +``` + +--- + +## Core Components + +### 1. GarminApp.mc +**Purpose**: Central application controller and data manager + +**Key Responsibilities**: +- Activity session lifecycle management (start/pause/resume/stop/save/discard) +- Cadence data collection and storage +- Cadence quality score computation +- State machine management +- Timer management +- Integration with Garmin Activity Recording API + +**Important Constants**: +```monkey-c +MAX_BARS = 280 // Maximum cadence samples to store +BASELINE_AVG_CADENCE = 160 // Minimum acceptable cadence +MAX_CADENCE = 190 // Maximum cadence for calculations +MIN_CQ_SAMPLES = 30 // Minimum samples for CQ calculation +DEBUG_MODE = true // Enable debug logging +``` + +**State Variables**: +- `_sessionState`: Current session state (IDLE/RECORDING/PAUSED/STOPPED) +- `activitySession`: Garmin ActivityRecording session object +- `_cadenceHistory`: Circular buffer storing 280 cadence samples +- `_cadenceBarAvg`: Rolling average buffer for chart display +- `_cqHistory`: Last 10 CQ scores for trend analysis + +--- + +## Data Flow + +### 1. Cadence Data Collection Pipeline + +``` +Cadence Sensor + ↓ +Activity.getActivityInfo().currentCadence + ↓ +updateCadenceBarAvg() [Every 1 second] + ↓ +_cadenceBarAvg buffer (accumulates samples) + ↓ +When buffer full (chart duration samples) + ↓ +Calculate bar average + ↓ +updateCadenceHistory(average) + ↓ +_cadenceHistory circular buffer [280 samples] + ↓ +computeCadenceQualityScore() + ↓ +_cqHistory [Last 10 scores] +``` + +### 2. Timer System + +**Global Timer** (`globalTimer`): +- Frequency: Every 1 second +- Callback: `updateCadenceBarAvg()` +- Runs: Always (from app start to stop) +- Purpose: Collect cadence data when recording + +**View Refresh Timers**: +- SimpleView: Refresh every 1 second +- AdvancedView: Refresh every 1 second +- Purpose: Update UI elements + +### 3. Data Averaging System + +The app uses a two-tier averaging system: + +**Tier 1: Bar Averaging** +``` +Chart Duration = 6 seconds (ThirtyminChart default) +↓ +Collect 6 cadence readings (1 per second) +↓ +Calculate average of these 6 readings +↓ +Store as single bar value +``` + +**Tier 2: Historical Storage** +``` +280 bar values stored +↓ +Each bar = average of 6 seconds +↓ +Total history = 280 Γ— 6 = 1680 seconds = 28 minutes +``` + +**Chart Duration Options**: +- FifteenminChart = 3 seconds per bar +- ThirtyminChart = 6 seconds per bar (default) +- OneHourChart = 13 seconds per bar +- TwoHourChart = 26 seconds per bar + +--- + +## State Management + +### Session State Machine + +``` +β”Œβ”€β”€β”€β”€β”€β”€β” +β”‚ IDLE β”‚ ← Initial state, no session +β””β”€β”€β”¬β”€β”€β”€β”˜ + β”‚ startRecording() + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ RECORDING β”‚ ← Activity running, timer active +β””β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”˜ + β”‚ β”‚ + β”‚ β”‚ pauseRecording() + β”‚ ↓ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ PAUSED β”‚ ← Activity paused, timer stopped + β”‚ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ β”‚ resumeRecording() + β”‚ ↓ + β”‚ (back to RECORDING) + β”‚ + β”‚ stopRecording() + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ STOPPED β”‚ ← Activity stopped, awaiting save/discard +β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ + β”‚ + β”‚ saveSession() or discardSession() + ↓ + (back to IDLE) +``` + +### State Transition Rules + +**IDLE β†’ RECORDING**: +- User presses START/STOP button +- Creates new ActivityRecording session +- Starts Garmin timer +- Resets all cadence data arrays +- Initializes timestamps + +**RECORDING β†’ PAUSED**: +- User selects "Pause" from menu +- Stops Garmin timer (timer pauses) +- Records pause timestamp +- Data collection stops + +**PAUSED β†’ RECORDING**: +- User selects "Resume" from menu +- Restarts Garmin timer +- Accumulates paused time +- Data collection resumes + +**RECORDING/PAUSED β†’ STOPPED**: +- User selects "Stop" from menu +- Stops Garmin timer +- Computes final CQ score +- Freezes all metrics +- Awaits save/discard decision + +**STOPPED β†’ IDLE**: +- User selects "Save": Saves to FIT file +- User selects "Discard": Deletes session +- Resets all data structures +- Ready for new session + +--- + +## Activity Recording System + +### Garmin ActivityRecording Integration + +**Session Creation** (`startRecording()`): +```monkey-c +activitySession = ActivityRecording.createSession({ + :name => "Running", + :sport => ActivityRecording.SPORT_RUNNING, + :subSport => ActivityRecording.SUB_SPORT_GENERIC +}); +activitySession.start(); +``` + +**What This Does**: +- Creates official Garmin activity +- Starts timer (visible in UI) +- Records GPS, heart rate, cadence automatically +- Manages distance calculation +- Handles sensor data collection + +**Pause/Resume** (`pauseRecording()` / `resumeRecording()`): +```monkey-c +// Pause +activitySession.stop(); // Pauses timer + +// Resume +activitySession.start(); // Resumes timer +``` + +**Save** (`saveSession()`): +```monkey-c +activitySession.save(); +``` +- Writes FIT file to device +- Syncs to Garmin Connect +- Appears in activity history +- Includes all sensor data + +**Discard** (`discardSession()`): +```monkey-c +activitySession.discard(); +``` +- Deletes session completely +- No FIT file created +- No sync to Garmin Connect + +--- + +## Cadence Quality Algorithm + +### Overview +The Cadence Quality (CQ) score is a composite metric (0-100%) evaluating running efficiency. + +### Components + +#### 1. Time in Zone Score (70% weight) + +**Purpose**: Measures percentage of time spent in ideal cadence range + +**Algorithm**: +``` +idealMin = 120 spm (default) +idealMax = 150 spm (default) + +inZoneCount = 0 +validSamples = 0 + +for each sample in _cadenceHistory: + if sample exists: + validSamples++ + if sample >= idealMin AND sample <= idealMax: + inZoneCount++ + +timeInZone = (inZoneCount / validSamples) Γ— 100 +``` + +**Example**: +- 200 valid samples +- 140 samples in zone (120-150) +- Score = (140/200) Γ— 100 = 70% + +#### 2. Smoothness Score (30% weight) + +**Purpose**: Measures cadence consistency (less variation = better) + +**Algorithm**: +``` +totalDiff = 0 +diffCount = 0 + +for i = 1 to MAX_BARS: + prev = _cadenceHistory[i-1] + curr = _cadenceHistory[i] + if both exist: + totalDiff += abs(curr - prev) + diffCount++ + +avgDiff = totalDiff / diffCount +rawScore = 100 - (avgDiff Γ— 10) +smoothness = clamp(rawScore, 0, 100) +``` + +**Interpretation**: +- avgDiff = 0-1: Very smooth (score ~90-100) +- avgDiff = 2-3: Normal (score ~70-80) +- avgDiff > 5: Erratic (score < 50) + +#### 3. Final CQ Score + +**Formula**: +``` +CQ = (timeInZone Γ— 0.7) + (smoothness Γ— 0.3) +``` + +**Example Calculation**: +``` +timeInZone = 75% +smoothness = 85% + +CQ = (75 Γ— 0.7) + (85 Γ— 0.3) + = 52.5 + 25.5 + = 78% +``` + +### CQ Confidence Level + +**Purpose**: Indicates reliability of CQ score + +**Factors**: +1. **Sample Count**: Need minimum 30 samples +2. **Missing Data Ratio**: Sensor dropout rate + +**Algorithm**: +``` +if samples < 30: + confidence = "Low" +else: + missingRatio = missingCount / (validCount + missingCount) + if missingRatio > 0.2: + confidence = "Low" + else if missingRatio > 0.1: + confidence = "Medium" + else: + confidence = "High" +``` + +### CQ Trend Analysis + +**Purpose**: Shows if cadence quality is improving during run + +**Algorithm**: +``` +Uses last 10 CQ scores (_cqHistory) + +if scores < 5: + trend = "Stable" +else: + delta = lastScore - firstScore + if delta < -5: + trend = "Declining" + else if delta > 5: + trend = "Improving" + else: + trend = "Stable" +``` + +### Ideal Cadence Calculator + +**Purpose**: Calculate personalized ideal cadence based on user profile + +**Formula** (gender-specific): + +**Male**: +``` +referenceCadence = (-1.268 Γ— legLength) + (3.471 Γ— speed) + 261.378 +``` + +**Female**: +``` +referenceCadence = (-1.190 Γ— legLength) + (3.705 Γ— speed) + 249.688 +``` + +**Other**: +``` +referenceCadence = (-1.251 Γ— legLength) + (3.665 Γ— speed) + 254.858 +``` + +**Experience Adjustment**: +``` +Beginner: multiplier = 1.06 (6% higher cadence) +Intermediate: multiplier = 1.04 (4% higher) +Advanced: multiplier = 1.02 (2% higher) + +finalCadence = referenceCadence Γ— multiplier +idealMin = finalCadence - 5 +idealMax = finalCadence + 5 +``` + +**Example**: +``` +User: Male, 170cm height, 10 km/h speed, Intermediate + +legLength = 170 Γ— 0.53 = 90.1 cm +speed = 10 / 3.6 = 2.78 m/s + +referenceCadence = (-1.268 Γ— 90.1) + (3.471 Γ— 2.78) + 261.378 + = -114.25 + 9.65 + 261.378 + = 156.78 + +adjusted = 156.78 Γ— 1.04 = 163.05 +final = round(163.05) = 163 +clamped = max(160, min(163, 190)) = 163 + +idealMin = 163 - 5 = 158 spm +idealMax = 163 + 5 = 168 spm +``` + +--- + +## User Interface + +### View Architecture + +#### SimpleView.mc +**Purpose**: Main activity tracking screen + +**Display Elements**: +1. **Timer**: HH:MM:SS format from Activity.getActivityInfo().timerTime +2. **Heart Rate**: BPM with heart icon (red) +3. **Cadence**: Current spm with cadence icon (green/red based on zone) +4. **Distance**: Kilometers with 2 decimals +5. **Cadence Zone**: Text showing if in/out of ideal range +6. **CQ Score**: Cadence Quality percentage +7. **State Indicator**: Visual recording state (REC/PAUSE/STOP) + +**Layout** (from top to bottom): +``` +[Timer: 00:00:00] [REC ●] + + ❀️ [Heart Rate] πŸƒ [Cadence] + + [Distance] km + + [Zone: In/Out (120-150)] + + CQ: [Score]% +``` + +**State Visual Indicators**: +- **IDLE**: "Press START/STOP to start" text +- **RECORDING**: Red dot + "REC" in top-right +- **PAUSED**: Yellow dot + "PAUSE" + flashing "PAUSED" text +- **STOPPED**: Green dot + "STOP" + "Activity Complete!" message + +#### AdvancedView.mc +**Purpose**: Real-time cadence visualization chart + +**Display Elements**: +1. **Timer**: Simplified format (H:MM) at top in yellow +2. **Heart Rate Circle**: Dark red circle on left with BPM +3. **Distance Circle**: Dark green circle on right with km +4. **Current Cadence**: Large centered text with spm +5. **Cadence Chart**: Histogram showing last 280 bars +6. **Chart Duration Label**: Shows time range (e.g., "Last 30 Minutes") + +**Chart Visualization**: +``` +Height of screen + ↓ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Time: 1:30 β”‚ +β”‚ β”‚ +β”‚ ●HR Cadence Dist●│ +β”‚ 150 170 2.5 β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ β–‚β–ƒβ–…β–‡β–ˆβ–‡β–…β–ƒβ–‚β–β–ƒβ–…β–‡β–ˆβ–‡ β”‚ β”‚ ← Chart +β”‚ β”‚ β–β–‚β–ƒβ–…β–‡β–ˆβ–‡β–…β–ƒβ–‚β–β–ƒβ–…β–‡β–ˆ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ Last 30 Minutes β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Color Coding**: +- **Green** (0x00bf63): Cadence in ideal zone +- **Blue** (0x0cc0df): Below zone but within 20 spm +- **Grey** (0x969696): More than 20 spm below zone +- **Orange** (0xff751f): Above zone but within 20 spm +- **Red** (0xFF0000): More than 20 spm above zone + +**Chart Algorithm**: +``` +For each bar in cadenceHistory: + barHeight = (cadence / MAX_CADENCE_DISPLAY) Γ— chartHeight + x = barZoneLeft + (barIndex Γ— barWidth) + y = barZoneBottom - barHeight + + color = determineColor(cadence, idealMin, idealMax) + drawRectangle(x, y, barWidth, barHeight, color) +``` + +### Navigation Flow + +``` +SimpleView (Main) + ↓ Swipe UP / Press DOWN +AdvancedView (Chart) + ↓ Swipe DOWN / Press UP +SimpleView (Main) + ↓ Swipe LEFT / Press MENU +Settings Menu + β”œβ”€β”€ Profile + β”œβ”€β”€ Customization + β”œβ”€β”€ Feedback + └── Cadence Range +``` + +### Button Mapping (Forerunner 165) + +**Physical Buttons**: +``` + [LIGHT/MENU] + ↓ + Settings + + [UP] ← WATCH β†’ [DOWN] +Settings AdvancedView + + [START/STOP] + ↓ + Main Control + + [BACK] + ↓ + Exit (if idle) +``` + +**START/STOP Button Behavior**: +| Current State | Action | Result | +|---------------|--------|--------| +| IDLE | Press | Start activity | +| RECORDING | Press | Show menu: Resume/Pause/Stop | +| PAUSED | Press | Show menu: Resume/Stop | +| STOPPED | Press | Show menu: Save/Discard | + +--- + +## Settings System + +### Architecture + +Settings use a hierarchical menu system with specialized delegates: + +``` +Settings Menu (SettingsMenuDelegate) + β”œβ”€β”€ Profile (SelectProfileDelegate) + β”‚ β”œβ”€β”€ Height (ProfilePickerDelegate) + β”‚ β”œβ”€β”€ Speed (ProfilePickerDelegate) + β”‚ β”œβ”€β”€ Experience (SelectExperienceDelegate) + β”‚ └── Gender (SelectGenderDelegate) + β”œβ”€β”€ Customization (SelectCustomizableDelegate) + β”‚ └── Chart Duration (SelectBarChartDelegate) + β”œβ”€β”€ Feedback (SelectFeedbackDelegate) + β”‚ β”œβ”€β”€ Haptic (SelectHapticDelegate) + β”‚ └── Audible (SelectAudibleDelegate) + └── Cadence Range + β”œβ”€β”€ Min Cadence (Picker) + └── Max Cadence (Picker) +``` + +### Profile Settings + +#### Height Setting +**Purpose**: Calculate leg length for ideal cadence +**Range**: 100-250 cm +**Default**: 170 cm +**UI**: Number picker with " cm" label + +**Implementation**: +```monkey-c +ProfilePickerFactory(100, 250, 1, {:label=>" cm"}) +Callback: ProfilePickerDelegate(:prof_height) +Storage: app.setUserHeight(value) +``` + +#### Speed Setting +**Purpose**: Running pace for cadence calculation +**Range**: 3-30 km/h +**Default**: 10 km/h +**UI**: Number picker with " km/h" label + +#### Experience Level +**Purpose**: Adjust ideal cadence by fitness level +**Options**: +- Beginner (1.06 multiplier) +- Intermediate (1.04 multiplier) +- Advanced (1.02 multiplier) +**Default**: Beginner +**UI**: Menu selection + +**Rationale**: Less experienced runners typically benefit from slightly higher cadence to reduce impact forces. + +#### Gender +**Purpose**: Gender-specific cadence formulas +**Options**: Male, Female, Other +**Default**: Male +**UI**: Menu selection + +### Customization Settings + +#### Chart Duration +**Purpose**: Set time range for cadence chart +**Options**: +- 15 Minutes (3 sec/bar) +- 30 Minutes (6 sec/bar) [Default] +- 1 Hour (13 sec/bar) +- 2 Hours (26 sec/bar) +**Effect**: Changes `_chartDuration` which affects bar averaging + +### Feedback Settings + +#### Haptic Feedback +**Purpose**: Vibration alerts for zone crossing +**Options**: On/Off +**Behavior**: +- Single pulse: Dropped below min cadence +- Double pulse: Exceeded max cadence + +#### Audible Feedback +**Purpose**: Audio alerts for zone crossing +**Options**: On/Off +**Behavior**: Beep patterns for zone events + +### Cadence Range Settings + +**Purpose**: Manually override ideal cadence zone + +**Min Cadence**: +- Range: 100-180 spm +- Default: 120 spm +- UI: Number picker + +**Max Cadence**: +- Range: 120-200 spm +- Default: 150 spm +- UI: Number picker + +**Use Case**: Advanced users who want custom zones based on training goals. + +--- + +## Features Reference + +### 1. Activity Session Management + +**Feature**: Full lifecycle control of running activities + +**Components**: +- Start: Begin new activity with Garmin session +- Pause: Temporarily stop timer and data collection +- Resume: Continue paused activity +- Stop: End activity (awaiting save/discard) +- Save: Write to FIT file and sync to Garmin Connect +- Discard: Delete activity without saving + +**User Flow**: +``` +Press START/STOP + ↓ +Activity starts (timer runs) + ↓ +Press START/STOP β†’ Select "Pause" + ↓ +Activity paused (timer stops) + ↓ +Press START/STOP β†’ Select "Resume" + ↓ +Activity resumes (timer continues) + ↓ +Press START/STOP β†’ Select "Stop" + ↓ +Activity stopped (timer frozen) + ↓ +Select "Save" or "Discard" + ↓ +Return to IDLE (ready for new activity) +``` + +### 2. Real-Time Cadence Monitoring + +**Feature**: Live cadence tracking with visual feedback + +**Data Source**: Activity.getActivityInfo().currentCadence +**Update Frequency**: Every 1 second +**Storage**: Circular buffer (280 samples = ~28 mins at default) + +**Visual Feedback**: +- **SimpleView**: Large cadence number with zone text +- **AdvancedView**: Color-coded histogram chart +- **Zone Indicator**: "In Zone" or "Out of Zone" text + +### 3. Cadence Quality Scoring + +**Feature**: Composite metric evaluating running efficiency + +**Algorithm**: Weighted combination of: +- Time in Zone (70%): Percentage in ideal range +- Smoothness (30%): Consistency of cadence + +**Output**: +- CQ Score: 0-100% +- Confidence: Low/Medium/High +- Trend: Improving/Stable/Declining + +**Update**: Real-time during recording, frozen when stopped + +### 4. Personalized Ideal Cadence + +**Feature**: Calculate optimal cadence based on user profile + +**Inputs**: +- Height (cm) +- Speed (km/h) +- Experience Level +- Gender + +**Output**: +- Ideal Min Cadence (spm) +- Ideal Max Cadence (spm) + +**Formula**: Gender-specific biomechanical equation with experience adjustment + +### 5. Historical Data Visualization + +**Feature**: Real-time cadence chart showing last 28 minutes + +**Chart Type**: Histogram (bar chart) +**Data Points**: 280 bars (each = average of 6 seconds) +**Color Coding**: 5-color gradient based on zone proximity +**Update**: Real-time (every second when recording) + +**Chart Duration Modes**: +- 15 min: Higher resolution (3 sec/bar) +- 30 min: Default (6 sec/bar) +- 1 hour: Lower resolution (13 sec/bar) +- 2 hours: Lowest resolution (26 sec/bar) + +### 6. Multi-Sensor Integration + +**Feature**: Display all relevant running metrics + +**Sensors**: +- **Cadence**: Steps per minute from cadence pod or wrist sensor +- **Heart Rate**: BPM from optical HR or chest strap +- **GPS**: Distance and speed +- **Timer**: Elapsed time (pauses with activity) + +**Display**: +- SimpleView: All metrics in text format +- AdvancedView: Heart rate and distance in circles + +### 7. Zone-Based Haptic Alerts + +**Feature**: Vibration feedback when leaving ideal zone + +**Triggers**: +- Single pulse: Cadence drops below min +- Double pulse: Cadence exceeds max + +**Implementation**: +- Tracks zone state (-1/0/1) +- Only triggers on state change +- Second pulse delayed 240ms + +### 8. Session Persistence + +**Feature**: Save activities to Garmin ecosystem + +**Save Format**: FIT file (Flexible and Interoperable Transfer) +**Storage Location**: Device internal storage +**Sync**: Automatic to Garmin Connect when synced +**Data Included**: +- Timer duration (excluding paused time) +- GPS track +- Heart rate +- Cadence samples +- Distance +- Speed/pace +- Custom CQ score (if supported) + +### 9. Memory Management + +**Feature**: Track and log memory usage + +**Implementation**: Logger.mc module +**Frequency**: +- On startup +- On shutdown +- Every ~60 seconds during runtime + +**Output**: System.println with stats +**Format**: `[MEMORY] Tag: used/total bytes (X% used)` + +### 10. Debug Logging + +**Feature**: Comprehensive logging for development + +**Enabled**: `DEBUG_MODE = true` +**Categories**: +- [INFO]: App lifecycle events +- [DEBUG]: Button presses, state changes +- [UI]: User interactions +- [CADENCE]: Cadence samples +- [CADENCE QUALITY]: CQ calculations +- [MEMORY]: Memory statistics + +**Toggle**: Set `DEBUG_MODE = false` for production + +--- + +## Data Structures + +### Circular Buffers + +**_cadenceHistory**: +``` +Type: Array[280] +Purpose: Store last 280 cadence bar averages +Access: Circular (wraps at 280) +Index: _cadenceIndex (0-279) +Count: _cadenceCount (0-280) +``` + +**_cadenceBarAvg**: +``` +Type: Array[_chartDuration] +Purpose: Temporary buffer for bar averaging +Access: Circular (wraps at chart duration) +Index: _cadenceAvgIndex +Count: _cadenceAvgCount +``` + +**_cqHistory**: +``` +Type: Array[10] +Purpose: Store last 10 CQ scores for trend +Access: Array (removes oldest when > 10) +``` + +### Session Metadata + +```monkey-c +_sessionStartTime: Number // System.getTimer() at start +_sessionPausedTime: Number // Total ms spent paused +_lastPauseTime: Number? // When current pause began +_finalCQ: Number? // Frozen CQ score when stopped +_finalCQConfidence: String? // Frozen confidence +_finalCQTrend: String? // Frozen trend +``` + +--- + +## Performance Considerations + +### Timer Efficiency +- **Global timer**: 1 second interval (low overhead) +- **View timers**: Only run when view is visible +- **Data collection**: O(1) operations (circular buffer) + +### Memory Usage +- **Cadence history**: 280 Γ— 4 bytes = 1120 bytes +- **Bar average**: 6 Γ— 4 bytes = 24 bytes (default) +- **CQ history**: 10 Γ— 4 bytes = 40 bytes +- **Total data**: ~1200 bytes (negligible on modern watches) + +### CPU Usage +- **Cadence update**: O(n) where n = chart duration (typically 6) +- **CQ calculation**: O(280) = O(1) for fixed size +- **Chart rendering**: O(280) bars drawn per frame + +### Battery Impact +- **GPS**: Major drain (handled by Garmin OS) +- **Sensors**: Minimal (optical HR, cadence) +- **Screen refresh**: 1 Hz (low power) +- **Recommendation**: Use with GPS activities (already optimized) + +--- + +## Future Enhancement Ideas + +### Visualization Enhancements + +#### 1. **Current Cadence Marker** ⭐ +**Priority**: High +**Complexity**: Low +**Effort**: 1-2 hours + +**Description**: Add a horizontal line or marker showing current real-time cadence on the chart + +**Implementation**: +```monkey-c +// In drawChart() after drawing bars: +if (info != null && info.currentCadence != null) { + var currentCadence = info.currentCadence; + var currentY = barZoneBottom - ((currentCadence / MAX_CADENCE_DISPLAY) * chartHeight); + + // Draw yellow horizontal line + dc.setColor(Graphics.COLOR_YELLOW, Graphics.COLOR_TRANSPARENT); + dc.drawLine(chartLeft, currentY, chartRight, currentY); + + // Optional: Draw small arrow or label + dc.fillCircle(chartRight + 5, currentY, 3); // Dot at end +} +``` + +**Benefits**: +- Instant visual reference for current performance +- Easy to see if cadence is trending up or down relative to history +- Helps runners maintain target cadence by comparing to past bars + +**User Experience**: +- Glanceable feedback during run +- No need to look at number - just see if line is in green zone + +--- + +#### 2. **Smooth Bars (Moving Average)** ⭐ +**Priority**: Medium +**Complexity**: Medium +**Effort**: 3-4 hours + +**Description**: Apply exponential moving average (EMA) or simple moving average to reduce visual "jumpiness" from sensor noise + +**Implementation Options**: + +**Option A: Simple Moving Average (SMA)** +```monkey-c +// Average last N bars for smoother display +private var _smoothedHistory as Array = new [MAX_BARS]; + +function smoothBars(windowSize as Number) as Void { + for (var i = 0; i < _cadenceCount; i++) { + var sum = 0.0; + var count = 0; + + // Average surrounding bars + for (var j = -windowSize; j <= windowSize; j++) { + var idx = (i + j + MAX_BARS) % MAX_BARS; + if (_cadenceHistory[idx] != null) { + sum += _cadenceHistory[idx]; + count++; + } + } + + _smoothedHistory[i] = (count > 0) ? (sum / count) : 0; + } +} +``` + +**Option B: Exponential Moving Average (EMA)** (Recommended) +```monkey-c +// Weighted average favoring recent data +private var _emaHistory as Array = new [MAX_BARS]; +private const SMOOTHING_FACTOR = 0.3; // Ξ± (0-1), lower = smoother + +function updateEMA(newValue as Float, index as Number) as Void { + if (index == 0 || _emaHistory[index-1] == null) { + _emaHistory[index] = newValue; + } else { + _emaHistory[index] = SMOOTHING_FACTOR * newValue + + (1 - SMOOTHING_FACTOR) * _emaHistory[index-1]; + } +} +``` + +**Configurable Settings**: +``` +Smoothing: Off / Low (Ξ±=0.5) / Medium (Ξ±=0.3) / High (Ξ±=0.1) +``` + +**Benefits**: +- Cleaner visual representation +- Reduces noise from sensor fluctuations +- Easier to spot genuine trends vs. random variation +- More professional appearance + +**Tradeoffs**: +- Slightly delayed response to actual changes +- May hide brief cadence spikes/drops +- Recommendation: Make it toggleable + +--- + +#### 3. **Fade Old Bars** ⭐ +**Priority**: Low +**Complexity**: Low +**Effort**: 1-2 hours + +**Description**: Apply opacity/alpha gradient to bars based on age - recent bars full opacity, older bars gradually fade + +**Implementation**: +```monkey-c +// Calculate fade based on bar age +for (var i = 0; i < numBars; i++) { + var index = (startIndex + i) % MAX_BARS; + var cadence = cadenceHistory[index]; + + // Calculate age factor (0.0 = oldest, 1.0 = newest) + var ageFactor = i / numBars.toFloat(); + + // Map to opacity (50% fade for oldest β†’ 100% for newest) + var minOpacity = 0.5; // Don't fade below 50% + var opacity = minOpacity + (ageFactor * (1.0 - minOpacity)); + + // Get base color + var baseColor = getColorForCadence(cadence); + + // Apply opacity (note: not all Garmin devices support alpha) + // Fallback: lighten color instead + dc.setColor(applyFade(baseColor, opacity), Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(x, y, barWidth, barHeight); +} + +function applyFade(color as Number, opacity as Float) as Number { + // Blend color with background (black) based on opacity + // For devices without alpha channel support + var r = ((color >> 16) & 0xFF) * opacity; + var g = ((color >> 8) & 0xFF) * opacity; + var b = (color & 0xFF) * opacity; + + return ((r.toNumber() << 16) | (g.toNumber() << 8) | b.toNumber()); +} +``` + +**Benefits**: +- Emphasizes recent data (what matters now, i think anyways) +- Creates visual depth/perspective +- Easier to focus on current performance +- More aesthetically pleasing + +**Configuration**: +``` +Fade: Off / Subtle (70-100%) / Medium (50-100%) / Strong (30-100%) +``` + +--- + +#### 4. **Zone Boundary Lines** +**Priority**: Medium +**Complexity**: Low +**Effort**: 30 minutes + +**Description**: Draw horizontal lines at idealMinCadence and idealMaxCadence for immediate visual reference + +**Implementation**: +```monkey-c +// Draw zone boundaries +var minY = barZoneBottom - ((idealMinCadence / MAX_CADENCE_DISPLAY) * chartHeight); +var maxY = barZoneBottom - ((idealMaxCadence / MAX_CADENCE_DISPLAY) * chartHeight); + +// Green dashed lines for zone boundaries +dc.setColor(0x00bf63, Graphics.COLOR_TRANSPARENT); // Green +dc.drawLine(chartLeft, minY, chartRight, minY); +dc.drawLine(chartLeft, maxY, chartRight, maxY); + +// Optional: Fill zone area with semi-transparent green +// (if device supports) +dc.setColor(0x00bf63, 0x20); // Green with alpha +dc.fillRectangle(chartLeft, maxY, chartWidth, minY - maxY); +``` + +**Benefits**: +- Clear visual target zone +- No need to remember numbers while running +- Instant feedback if bars cross boundaries +- Reduces cognitive load + +--- + +### Chart Optimization & Performance + +#### 5. **Reduce Redraw Cost (Battery Optimization)** ⭐⭐⭐ +**Priority**: High +**Complexity**: Medium +**Effort**: 4-6 hours + +**Description**: Implement intelligent redraw strategies to minimize unnecessary screen updates + +**Strategy 1: Dirty Region Tracking** +```monkey-c +private var _lastDrawnCadence = 0; +private var _lastDrawnBarCount = 0; +private var _lastDrawnZone = [0, 0]; // [min, max] + +function needsRedraw() as Boolean { + var currentCadence = getCurrentCadence(); + + // Redraw if: + // 1. New bar added (every ~6 seconds) + if (_cadenceCount != _lastDrawnBarCount) { return true; } + + // 2. Current cadence changed significantly (>2 spm) + if (Math.abs(currentCadence - _lastDrawnCadence) > 2) { return true; } + + // 3. Zone settings changed + if (_idealMinCadence != _lastDrawnZone[0] || + _idealMaxCadence != _lastDrawnZone[1]) { return true; } + + return false; +} + +function onUpdate(dc as Dc) as Void { + if (needsRedraw()) { + View.onUpdate(dc); + drawElements(dc); + + // Update tracking + _lastDrawnCadence = getCurrentCadence(); + _lastDrawnBarCount = _cadenceCount; + _lastDrawnZone = [_idealMinCadence, _idealMaxCadence]; + } +} +``` + +**Strategy 2: Partial Chart Updates** +```monkey-c +// Only redraw new bars, not entire chart +private var _lastRenderedBarIndex = 0; + +function drawNewBarsOnly(dc as Dc) as Void { + // Calculate how many new bars since last draw + var newBars = _cadenceIndex - _lastRenderedBarIndex; + + if (newBars <= 0) { return; } // No new data + + // Set clip region to only new bar area + var newBarX = chartLeft + (_lastRenderedBarIndex * barWidth); + var clipWidth = newBars * barWidth; + + dc.setClip(newBarX, chartTop, clipWidth, chartHeight); + + // Draw only new bars + for (var i = _lastRenderedBarIndex; i < _cadenceIndex; i++) { + drawSingleBar(dc, i); + } + + dc.clearClip(); + _lastRenderedBarIndex = _cadenceIndex; +} +``` + +**Strategy 3: Adaptive Refresh Rate** +```monkey-c +private var _refreshRate = 1000; // Default 1 Hz + +function updateRefreshRate() as Void { + if (_sessionState == PAUSED) { + _refreshRate = 5000; // 0.2 Hz when paused + } else if (_sessionState == STOPPED) { + _refreshRate = 10000; // 0.1 Hz when stopped + } else { + _refreshRate = 1000; // 1 Hz when recording + } + + // Restart timer with new rate + if (_simulationTimer != null) { + _simulationTimer.stop(); + _simulationTimer.start(method(:refreshScreen), _refreshRate, true); + } +} +``` + +**Expected Impact**: +- **10-20% battery improvement** during long activities +- **30-40% reduction** in unnecessary screen updates +- **Smoother performance** on lower-end devices + +--- + +#### 6. **Chart Rendering Optimization** +**Priority**: High +**Complexity**: Medium +**Effort**: 3-4 hours + +**Description**: Optimize the chart drawing loop using cached calculations and efficient rendering + +**Optimization 1: Pre-calculate Bar Positions** +```monkey-c +private var _barPositions as Array = new [MAX_BARS]; + +function precalculateBarPositions() as Void { + var barWidth = (barZoneWidth / MAX_BARS).toNumber(); + + for (var i = 0; i < MAX_BARS; i++) { + var x = barZoneLeft + i * barWidth; + _barPositions[i] = [x, barWidth]; // Store x and width + } +} + +// In drawChart(): +for (var i = 0; i < numBars; i++) { + var x = _barPositions[i][0]; + var barWidth = _barPositions[i][1]; + // ... rest of drawing +} +``` + +**Optimization 2: Color Lookup Table** +```monkey-c +private var _colorCache as Dictionary = {}; + +function getColorCached(cadence as Number) as Number { + var key = cadence.toNumber(); // Round to integer + + if (_colorCache.hasKey(key)) { + return _colorCache[key]; + } + + var color = calculateColor(cadence); + _colorCache.put(key, color); + return color; +} +``` + +**Optimization 3: Batch Drawing Operations** +```monkey-c +// Group bars by color to reduce setColor() calls +var colorGroups = {}; + +for (var i = 0; i < numBars; i++) { + var color = getColor(cadence); + if (!colorGroups.hasKey(color)) { + colorGroups[color] = []; + } + colorGroups[color].add(barData); +} + +// Draw all bars of same color together +foreach (var color in colorGroups.keys()) { + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + foreach (var bar in colorGroups[color]) { + dc.fillRectangle(bar.x, bar.y, bar.width, bar.height); + } +} +``` + +**Impact**: **50-70% reduction** in chart draw time + +--- + +### Advanced Chart Features + +#### 7. **Auto-Adjust Averaging Based on Zone Width** +**Priority**: Low +**Complexity**: Medium +**Effort**: 2-3 hours + +**Description**: Automatically adjust chart duration (samples per bar) based on cadence zone range + +**Logic**: +```monkey-c +function calculateOptimalChartDuration() as Number { + var zoneRange = _idealMaxCadence - _idealMinCadence; + + // Narrow zone β†’ Higher resolution + if (zoneRange <= 5) { + return 3; // 3 sec/bar (high detail for precision) + } + // Normal zone β†’ Default resolution + else if (zoneRange <= 15) { + return 6; // 6 sec/bar (balanced) + } + // Wide zone β†’ Lower resolution (smoother) + else if (zoneRange <= 30) { + return 13; // 13 sec/bar (reduce noise) + } + // Very wide zone β†’ Overview mode + else { + return 26; // 26 sec/bar (big picture) + } +} + +// Call when zone changes: +function onZoneChanged() as Void { + _chartDuration = calculateOptimalChartDuration(); + resizeAveragingBuffer(_chartDuration); +} +``` + +**Benefits**: +- Optimal granularity for any zone width +- Narrow zones (e.g., 148-152) get fine detail +- Wide zones (e.g., 120-160) get smoothed data +- Automatic - no user configuration needed + +**Example**: +- Zone 98-99 (range=1): 3 sec/bar β†’ 280 bars = 14 min history +- Zone 140-155 (range=15): 6 sec/bar β†’ 280 bars = 28 min history +- Zone 100-150 (range=50): 26 sec/bar β†’ 280 bars = 121 min history + +--- + +#### 8. **Statistical Overlays** +**Priority**: Medium +**Complexity**: Medium +**Effort**: 3-4 hours + +**Description**: Display statistical information on chart (mean, median, trend line) + +**Implementation**: +```monkey-c +function calculateStats() as Dictionary { + var sum = 0.0; + var count = 0; + var sortedData = []; + + for (var i = 0; i < _cadenceCount; i++) { + if (_cadenceHistory[i] != null) { + sum += _cadenceHistory[i]; + count++; + sortedData.add(_cadenceHistory[i]); + } + } + + var mean = count > 0 ? sum / count : 0; + + // Calculate median + sortedData = sortData(sortedData); + var median = sortedData[count / 2]; + + // Calculate standard deviation + var variance = 0.0; + for (var i = 0; i < count; i++) { + variance += Math.pow(_cadenceHistory[i] - mean, 2); + } + var stdDev = Math.sqrt(variance / count); + + return { + :mean => mean, + :median => median, + :stdDev => stdDev + }; +} + +// Draw mean line on chart +var stats = calculateStats(); +var meanY = barZoneBottom - ((stats[:mean] / MAX_CADENCE_DISPLAY) * chartHeight); +dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); +dc.drawLine(chartLeft, meanY, chartRight, meanY); // Dashed line + +// Draw std dev band +var stdDevTop = meanY - (stats[:stdDev] / MAX_CADENCE_DISPLAY) * chartHeight; +var stdDevBottom = meanY + (stats[:stdDev] / MAX_CADENCE_DISPLAY) * chartHeight; +dc.setColor(0xFFFFFF, 0x40); // Semi-transparent white +dc.fillRectangle(chartLeft, stdDevTop, chartWidth, stdDevBottom - stdDevTop); +``` + +**Display**: +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ β–„β–„β–„ β–„β–„ β”‚ +β”‚ β–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–„ β”‚ ← Std dev band (Β±5 spm) +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ ← Mean line (148 spm) +β”‚ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +Text overlay: "Avg: 148Β±5 spm" +``` + +--- + +### User Experience Enhancements + +#### 9. **Configurable Refresh Rate** +**Priority**: Medium +**Complexity**: Low +**Effort**: 1-2 hours + +**Description**: User setting for screen update frequency to balance responsiveness vs. battery + +**Settings Options**: +``` +Display Refresh Rate: +[ ] Battery Saver (0.5 Hz - every 2 sec) +[βœ“] Balanced (1 Hz - every 1 sec) [DEFAULT] +[ ] Performance (2 Hz - twice per sec) +``` + +**Implementation**: +```monkey-c +enum RefreshRate { + BATTERY_SAVER = 2000, // 0.5 Hz + BALANCED = 1000, // 1 Hz + PERFORMANCE = 500 // 2 Hz +} + +private var _refreshRate = RefreshRate.BALANCED; + +function setRefreshRate(rate as RefreshRate) as Void { + _refreshRate = rate; + if (_simulationTimer != null) { + _simulationTimer.stop(); + _simulationTimer.start(method(:refreshScreen), rate, true); + } +} +``` + +**Benefits**: +- Ultra-runners can extend battery life +- Interval trainers get higher responsiveness +- User control over performance/battery tradeoff + +--- + +#### 10. **Smart Alerts (Context-Aware)** +**Priority**: Medium +**Complexity**: High +**Effort**: 4-5 hours + +**Description**: Intelligent haptic feedback that considers context + +**Features**: +```monkey-c +// Don't alert during warm-up period +private const WARMUP_DURATION = 300000; // 5 minutes + +// Gradient alert intensity +function triggerCadenceAlert(cadence as Number) as Void { + var elapsed = System.getTimer() - _sessionStartTime; + + // Suppress during warm-up + if (elapsed < WARMUP_DURATION) { return; } + + var deviation = 0; + if (cadence < _idealMinCadence) { + deviation = _idealMinCadence - cadence; + } else if (cadence > _idealMaxCadence) { + deviation = cadence - _idealMaxCadence; + } + + // Intensity based on deviation + if (deviation > 10) { + tripleVibration(); // Urgent + } else if (deviation > 5) { + doubleVibration(); // Warning + } else if (deviation > 0) { + singleVibration(); // Gentle reminder + } +} + +// Consider terrain (if GPS elevation available) +function adjustZoneForTerrain() as Void { + var grade = calculateGrade(); // From GPS + + if (grade > 5) { // Uphill > 5% + _adjustedMin = _idealMinCadence - 5; + _adjustedMax = _idealMaxCadence - 5; + } else if (grade < -5) { // Downhill > 5% + _adjustedMin = _idealMinCadence + 5; + _adjustedMax = _idealMaxCadence + 5; + } +} +``` + +--- + +### Data & Analytics + +#### 11. **Export to CSV** +**Priority**: Low +**Complexity**: Medium +**Effort**: 3-4 hours + +**Description**: Generate CSV file for external analysis + +**Format**: +```csv +Timestamp,Cadence,Zone,HeartRate,Distance,CQ_Score +00:00:06,145,Below,152,0.02,-- +00:00:12,148,In,155,0.05,-- +00:00:18,151,In,158,0.08,67 +... +``` + +**Implementation**: +```monkey-c +function exportToCSV() as String { + var csv = "Timestamp,Cadence,Zone,HeartRate,Distance,CQ_Score\n"; + + for (var i = 0; i < _cadenceCount; i++) { + var time = formatTime(i * _chartDuration); + var cadence = _cadenceHistory[i]; + var zone = getZoneLabel(cadence); + + csv += time + "," + cadence + "," + zone + "," + + getHR(i) + "," + getDist(i) + "," + getCQ(i) + "\n"; + } + + return csv; +} +``` + +**Note**: Garmin devices have limited file I/O. May need to: +- Store in string and copy via Garmin Connect IQ +- Or upload to companion app via Bluetooth + +--- + +### Performance Metrics + +#### 12. **Dynamic Memory Management** +**Priority**: Medium +**Complexity**: High +**Effort**: 5-6 hours + +**Description**: Adapt buffer sizes based on available memory + +**Implementation**: +```monkey-c +function initializeWithMemoryCheck() as Void { + var stats = System.getSystemStats(); + var freeMemory = stats.freeMemory; + var totalMemory = stats.totalMemory; + var usagePercent = (totalMemory - freeMemory) / totalMemory.toFloat(); + + // Conservative if low memory + if (freeMemory < 50000 || usagePercent > 0.75) { + MAX_BARS = 140; // 14 min @ 6 sec/bar + System.println("[MEMORY] Low memory mode: 140 bars"); + } + // Aggressive if plenty of memory + else if (freeMemory > 200000) { + MAX_BARS = 560; // 56 min @ 6 sec/bar + System.println("[MEMORY] Extended mode: 560 bars"); + } + // Standard + else { + MAX_BARS = 280; // 28 min @ 6 sec/bar + } + + _cadenceHistory = new [MAX_BARS]; +} +``` + +**Benefits**: +- Prevents out-of-memory crashes +- Better device compatibility +- Graceful degradation on constrained devices + +--- + +## Implementation Priority Matrix + +### πŸ”΄ High Priority ? Maybe +1. **Current Cadence Marker** +2. **Battery Optimization** +3. **Chart Rendering Optimization** + +### 🟑 Medium Priority +4. **Smooth Bars** +5. **Zone Boundary Lines** +6. **Configurable Refresh Rate** +7. **Statistical Overlays** +8. **Smart Alerts** + +### 🟒 Low Priority +9. **Fade Old Bars** +10. **Auto-Adjust Chart Duration** +11. **CSV Export** +12. **Dynamic Memory** + +--- + +## Technical Debt & Code Quality & other ramblign thoughts + +### Refactoring Needed +- [ ] Extract chart rendering to `ChartRenderer.mc` class +- [ ] Create `CircularBuffer.mc` reusable class +- [ ] Consolidate color constants into `Colors.mc` +- [ ] Add input validation layer for all settings +- [ ] Document all public methods with JSDoc-style comments + +### Testing & Quality +- [ ] Add unit tests for CQ algorithm +- [ ] Add integration tests for state machine +- [ ] Profile memory usage during 2+ hour activities +- [ ] Benchmark chart rendering on FR165 vs FR165 Music +- [ ] Test sensor disconnection recovery + +### Performance Profiling Targets +- [ ] Chart draw time: <50ms per frame +- [ ] Memory usage: <5% of total device memory +- [ ] Battery drain: <5% per hour (GPS active) + +--- + +## Debugging Guide + +### Common Issues + +**Issue**: Timer not pausing +**Cause**: ActivityRecording session not properly controlled +**Solution**: Check `activitySession.stop()` is called on pause + +**Issue**: Cadence data not collecting +**Cause**: State not RECORDING or sensor not connected +**Solution**: Verify `_sessionState == RECORDING` and sensor paired + +**Issue**: CQ always shows "--" +**Cause**: Less than MIN_CQ_SAMPLES (30) collected +**Solution**: Wait 30 seconds after starting, check sensor connection + +**Issue**: Chart not updating +**Cause**: View timer not running or data not flowing +**Solution**: Check `_simulationTimer` started in `onShow()` + +### Debug Checklist + +1. βœ“ `DEBUG_MODE = true` in GarminApp.mc +2. βœ“ Watch console for `[INFO]`, `[DEBUG]`, `[CADENCE]` messages +3. βœ“ Verify state transitions match expected flow +4. βœ“ Check `_cadenceCount` increments when recording +5. βœ“ Confirm `activitySession != null` when active +6. βœ“ Validate sensor pairing in Garmin Connect app + +--- + +## Version History + +**Current Version**: 1.0 (January 2026) + +**Changes from Original**: +- βœ“ Fixed: Uncommented critical recording check (line 270) +- βœ“ Added: Full state machine (IDLE/RECORDING/PAUSED/STOPPED) +- βœ“ Added: Pause/Resume functionality +- βœ“ Added: Save/Discard workflow +- βœ“ Added: Garmin ActivityRecording integration +- βœ“ Added: Menu system for activity control +- βœ“ Fixed: Timer now properly pauses/resumes +- βœ“ Added: Visual state indicators +- βœ“ Added: Comprehensive documentation + +**Known Limitations**: +- No persistent storage of CQ history +- No lap/split functionality +- No custom alert thresholds +- No data export capability +- Haptic feedback placeholder (device-dependent) + +--- + +## Glossary + +**CQ**: Cadence Quality - composite score measuring running efficiency +**FIT File**: Flexible and Interoperable Transfer - Garmin's activity file format +**SPM**: Steps Per Minute - cadence measurement unit +**Circular Buffer**: Fixed-size buffer that wraps when full +**Activity Session**: Garmin's ActivityRecording instance managing timer/sensors +**State Machine**: System that transitions between defined states based on events +**Delegate Pattern**: Separation of input handling from view logic +**MVC**: Model-View-Controller architecture pattern + +--- + +## Credits + +**Application**: Garmin Cadence Monitoring App for Forerunner 165 +**Platform**: Garmin Connect IQ SDK 8.3.0 +**Language**: Monkey C +**Target API**: 5.2.0+ +**Documentation Version**: 1.0 +**Last Updated**: January 2026 +** S +## Special Mentions +**Dom +**Chum +**jack +**Kyle +**Jin + + +--- + + diff --git a/combined-source.txt b/combined-source.txt new file mode 100644 index 0000000..a69446d --- /dev/null +++ b/combined-source.txt @@ -0,0 +1,5301 @@ +ο»Ώ===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\resources\drawables\drawables.xml ===== + + + + + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\resources\layouts\layout.xml ===== + + + + + + + + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\resources\menus\menu.xml ===== + + + + + + + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\resources\strings\strings.xml ===== + + + TestingCadence + + Click the menu button + + Item 1 + Item 2 + + + Set Min Cadence + Set Max Cadence + + + Increase Min + Decrease Min + Increase Max + Decrease Max + + Reset Zones + In Zone + OutZone + + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\CustomizableDelegates\SelectBarChartDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectBarChartDelegate extends WatchUi.Menu2InputDelegate { + + private var _menu as WatchUi.Menu2; + var app = getApp(); + var chartDuration = app.getChartDuration(); + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + _menu = menu; + var newTitle = Lang.format("Chart: $1$", [chartDuration]); + + // This updates the UI when the chart duration is changed + _menu.setTitle(newTitle); + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Try to change cadence range based off menu selection + if (id == :chart_15m){ + app.setChartDuration(GarminApp.FifteenminChart); + } + else if (id == :chart_30m){ + app.setChartDuration(GarminApp.ThirtyminChart); + } + else if (id == :chart_1h){ + app.setChartDuration(GarminApp.OneHourChart); + } + else if (id == :chart_2h){ + app.setChartDuration(GarminApp.TwoHourChart); + } + else {System.println("ERROR");} + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + + } + + function onMenuItem(item as Symbol) as Void {} + + //returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\CustomizableDelegates\SelectCustomizableDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectCutomizableDelegate extends WatchUi.Menu2InputDelegate { + + //private var _menu as WatchUi.Menu2; + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + //_menu = menu; + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Add if more customizable options are added + if (id == :cust_bar_chart){ + pushBarChartMenu(); + } + else {System.println("ERROR");} + + } + + function pushBarChartMenu() as Void { + var menu = new WatchUi.Menu2({ + :title => "Bar Chart Length:" + }); + + menu.addItem(new WatchUi.MenuItem("15 Minute", null, :chart_15m, null)); + menu.addItem(new WatchUi.MenuItem("30 Minute", null, :chart_30m, null)); + menu.addItem(new WatchUi.MenuItem("1 Hour", null, :chart_1h, null)); + menu.addItem(new WatchUi.MenuItem("2 Hour", null, :chart_2h, null)); + + //pushes the view to the screen with the relevent delegate + WatchUi.pushView(menu, new SelectBarChartDelegate(menu), WatchUi.SLIDE_LEFT); + } + + function onMenuItem(item as Symbol) as Void {} + + //returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\FeedbackDelegates\SelectAudibleDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectAudibleDelegate extends WatchUi.Menu2InputDelegate { + + private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + //var Audible = app.getAudible(); + var Audible = "low";// make sure to change to above!! - after feature has been added + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + _menu = menu; + + var newTitle = Lang.format("Audible: $1$", [Audible]); + + // This updates the UI when the cadence is changed + _menu.setTitle(newTitle); + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Try to change cadence range based off menu selection + if (id == :audible_low){ + System.println("Audible Feedback: LOW"); + //app.setAudible("low"); + } + else if (id == :audible_med){ + System.println("Audible Feedback: MEDIUM"); + //app.setUserAudible("med"); + } + else if (id == :audible_high){ + System.println("Audible Feedback: HIGH"); + //app.setUserAudible("high"); + } else {System.println("ERROR");} + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + + function onMenuItem(item as Symbol) as Void {} + + // Returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\FeedbackDelegates\SelectFeedbackDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectFeedbackDelegate extends WatchUi.Menu2InputDelegate { + + //private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + //var experienceLvl = app.getUserGender(); + var gender = "Other";// make sure to change to above!! + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + //_menu = menu; + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Try to change cadence range based off menu selection + if (id == :haptic_feedback){ + System.println("Haptic menu selected"); + pushHapticSettings(); + } + else if (id == :audible_feedback){ + System.println("Audible menu selected"); + pushAudibleSettings(); + } else {System.println("ERROR");} + + } + + function pushHapticSettings() as Void{ + var menu = new WatchUi.Menu2({ + :title => "Haptic Settings" + }); + //temp items since feedback has not yet been implemented + menu.addItem(new WatchUi.MenuItem("Low", null, :haptic_low, null)); + menu.addItem(new WatchUi.MenuItem("Medium", null, :haptic_med, null)); + menu.addItem(new WatchUi.MenuItem("High", null, :haptic_high, null)); + + //pushes the view to the screen with the relevent delegate + WatchUi.pushView(menu, new SelectHapticDelegate(menu), WatchUi.SLIDE_LEFT); + } + + function pushAudibleSettings() as Void{ + var menu = new WatchUi.Menu2({ + :title => "Audible Settings" + }); + + menu.addItem(new WatchUi.MenuItem("Low", null, :audible_low, null)); + menu.addItem(new WatchUi.MenuItem("Medium", null, :audible_med, null)); + menu.addItem(new WatchUi.MenuItem("High", null, :audible_high, null)); + + //pushes the view to the screen with the relevent delegate + WatchUi.pushView(menu, new SelectAudibleDelegate(menu), WatchUi.SLIDE_LEFT); + } + + function onMenuItem(item as Symbol) as Void {} + + // Returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\FeedbackDelegates\SelectHapticDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectHapticDelegate extends WatchUi.Menu2InputDelegate { + + private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + //var haptic = app.getHaptic(); + var haptic = "low";// make sure to change to above!! - after feature has been added + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + _menu = menu; + + var newTitle = Lang.format("Haptic: $1$", [haptic]); + + // This updates the UI when the cadence is changed + _menu.setTitle(newTitle); + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Try to change cadence range based off menu selection + if (id == :haptic_low){ + System.println("Haptic Feedback: LOW"); + //app.setHaptic("low"); + } + else if (id == :haptic_med){ + System.println("Haptic Feedback: MEDIUM"); + //app.setUserHaptic("med"); + } + else if (id == :haptic_high){ + System.println("Haptic Feedback: HIGH"); + //app.setUserHaptic("high"); + } else {System.println("ERROR");} + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + + function onMenuItem(item as Symbol) as Void {} + + // Returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\ProfileDelegates\ProfilePickerDelegate.mc ===== + +import Toybox.WatchUi; +import Toybox.System; +import Toybox.Application; +import Toybox.Lang; + +class ProfilePickerDelegate extends WatchUi.PickerDelegate { + + private var _typeId; + + function initialize(typeId) { + PickerDelegate.initialize(); + _typeId = typeId; + } + + function onAccept(values as Array) as Boolean { + var pickedValue = values[0]; // Gets the "selected" value + + var app = Application.getApp() as GarminApp; + + if (_typeId == :prof_height) { + System.println("Height Saved: " + pickedValue); + app.setUserHeight(pickedValue); + } + else if (_typeId == :prof_speed) { + System.println("Speed Saved: " + pickedValue); + app.setUserSpeed(pickedValue); + } + + app.idealCadenceCalculator(); + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } + + function onCancel() as Boolean { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\ProfileDelegates\ProfilePickerFactory.mc ===== + +import Toybox.WatchUi; +import Toybox.Graphics; +import Toybox.Lang; + +class ProfilePickerFactory extends WatchUi.PickerFactory { + private var _start as Number; + private var _stop as Number; + private var _increment as Number; + private var _label as String; + + function initialize(start as Number, stop as Number, increment as Number, options as Dictionary?) { + PickerFactory.initialize(); + _start = start; + _stop = stop; + _increment = increment; + _label = ""; + + if (options != null) { + if (options.hasKey(:label)) { + _label = options[:label] as String; + } + } + } + + function getSize() as Number { + return (_stop - _start) / _increment + 1; + } + + function getValue(index as Number) as Object? { + return _start + (index * _increment); + } + + function getDrawable(index as Number, selected as Boolean) as Drawable? { + + // gets the selected value + var val = getValue(index); + + // converts to number if needed + if (val has :toNumber) { + val = val.toNumber(); + } + + // string that is displayed (e.g. "175" + " cm") + var displayString = Lang.format("$1$$2$", [val, _label]); + + return new WatchUi.Text({ + :text => displayString, + :color => Graphics.COLOR_WHITE, + :font => Graphics.FONT_MEDIUM, + :locX => WatchUi.LAYOUT_HALIGN_CENTER, + :locY => WatchUi.LAYOUT_VALIGN_CENTER + }); + } + + function getIndex(value as Number) as Number { + + var safeValue = value; + if (safeValue has :toNumber) { + safeValue = safeValue.toNumber(); + } + + return (safeValue - _start) / _increment; + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\ProfileDelegates\SelectExperienceDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectExperienceDelegate extends WatchUi.Menu2InputDelegate { + + private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + var experienceLvl = app.getExperienceLvl(); + var experienceLvlString = "NULL"; + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + _menu = menu; + + if (experienceLvl == GarminApp.Beginner){ + experienceLvlString = "Beginner"; + } else if (experienceLvl == GarminApp.Intermediate){ + experienceLvlString = "Intermediate"; + } else if (experienceLvl == GarminApp.Advanced){ + experienceLvlString = "Advanced"; + } + var newTitle = Lang.format("Experience: $1$", [experienceLvlString]); + + // This updates the UI when the experience level is changed + _menu.setTitle(newTitle); + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Try to change user experience lvl based off menu selection + if (id == :exp_beginner){ + System.println("User ExperienceLvl: Beginner"); + app.setExperienceLvl(GarminApp.Beginner); + } + else if (id == :exp_intermediate){ + System.println("User ExperienceLvl: Intermediate"); + app.setExperienceLvl(GarminApp.Intermediate); + } + else if (id == :exp_advanced){ + System.println("User ExperienceLvl: Advanced"); + app.setExperienceLvl(GarminApp.Advanced); + } else {System.println("ERROR");} + + app.idealCadenceCalculator(); + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + + } + + + function onMenuItem(item as Symbol) as Void {} + + // Returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\ProfileDelegates\SelectGenderDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class SelectGenderDelegate extends WatchUi.Menu2InputDelegate { + + private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + var gender = app.getUserGender(); + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + _menu = menu; + + // need if statements to display experiencelvl string instead of float values + var newTitle = Lang.format("Gender: $1$", [gender]); + + // This updates the UI when the cadence is changed + _menu.setTitle(newTitle); + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //Try to change user gender based off menu selection + if (id == :user_male){ + app.setUserGender(GarminApp.Male); + System.println("User Gender: Male"); + } + else if (id == :user_female){ + app.setUserGender(GarminApp.Female); + System.println("User Gender: Female"); + } + else if (id == :user_other){ + app.setUserGender(GarminApp.Other); + System.println("User Gender: Other"); + } else {System.println("ERROR");} + + app.idealCadenceCalculator(); + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + + function onMenuItem(item as Symbol) as Void {} + + // Returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\ProfileDelegates\SelectProfileDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; +import Toybox.Graphics; + +class SelectProfileDelegate extends WatchUi.Menu2InputDelegate { + + //private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + //_menu = menu; + } + + function onSelect(item) as Void { + + var id = item.getId(); + + //displays the menu for the selected item + if (id == :profile_height){ + heightPicker(); + } + else if (id == :profile_speed){ + speedPicker(); + } + else if (id == :profile_experience){ + experienceMenu(); + } + else if (id == :profile_gender){ + genderMenu(); + } + } + + function onMenuItem(item as Symbol) as Void {} + + // Returns back one menu + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + + function heightPicker() as Void { + + var currentHeight = app.getUserHeight(); + if (currentHeight == null) { currentHeight = 175; } // Default 175 cm + + var factory = new ProfilePickerFactory(100, 250, 1, {:label=>" cm"}); + + var picker = new WatchUi.Picker({ + :title => new WatchUi.Text({:text=>"Set Height", :locX=>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, :color=>Graphics.COLOR_WHITE}), + :pattern => [factory], + :defaults => [factory.getIndex(currentHeight)] + }); + + WatchUi.pushView(picker, new ProfilePickerDelegate(:prof_height), WatchUi.SLIDE_LEFT); + + } + + function speedPicker() as Void { + //uses number not float + var currentSpeed = app.getUserSpeed().toNumber(); + if (currentSpeed == null) { currentSpeed = 3; } // Default 3 km/h + + var factory = new ProfilePickerFactory(3, 30, 1, {:label=>" km/h"}); + + var picker = new WatchUi.Picker({ + :title => new WatchUi.Text({:text=>"Set Speed", :locX=>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, :color=>Graphics.COLOR_WHITE}), + :pattern => [factory], + :defaults => [factory.getIndex(currentSpeed)] + }); + + WatchUi.pushView(picker, new ProfilePickerDelegate(:prof_speed), WatchUi.SLIDE_LEFT); + + } + + function experienceMenu() as Void { + var menu = new WatchUi.Menu2({ + :title => "Set Experience" + }); + + menu.addItem(new WatchUi.MenuItem("Beginner", null, :exp_beginner, null)); + menu.addItem(new WatchUi.MenuItem("Intermediate", null, :exp_intermediate, null)); + menu.addItem(new WatchUi.MenuItem("Advanced", null, :exp_advanced, null)); + + //pushes the view to the screen with the relevent delegate + WatchUi.pushView(menu, new SelectExperienceDelegate(menu), WatchUi.SLIDE_LEFT); + } + + function genderMenu() as Void { + var menu = new WatchUi.Menu2({ + :title => "Set Gender" + }); + + menu.addItem(new WatchUi.MenuItem("Male", null, :user_male, null)); + menu.addItem(new WatchUi.MenuItem("Female", null, :user_female, null)); + menu.addItem(new WatchUi.MenuItem("Other", null, :user_other, null)); + + //pushes the view to the screen with the relevent delegate + WatchUi.pushView(menu, new SelectGenderDelegate(menu), WatchUi.SLIDE_LEFT); + } + +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\CadenceRangePickerDelegate.mc ===== + +import Toybox.WatchUi; +import Toybox.System; +import Toybox.Application; +import Toybox.Lang; + +class CadenceRangePickerDelegate extends WatchUi.PickerDelegate { + + private var _typeId; + private var _menu; + + function initialize(typeId, menu) { + PickerDelegate.initialize(); + _typeId = typeId; + _menu = menu; + System.println("[DEBUG] CadenceRangePickerDelegate initialized with typeId: " + typeId); + } + + function onAccept(values as Array) as Boolean { + System.println("[DEBUG] CadenceRangePickerDelegate onAccept called"); + + var pickedValue = values[0]; // Gets the "selected" value + System.println("[DEBUG] Picked value: " + pickedValue); + + var app = Application.getApp() as GarminApp; + + if (_typeId == :cadence_min) { + System.println("[INFO] Min Cadence Saved: " + pickedValue); + app.setMinCadence(pickedValue); + } + else if (_typeId == :cadence_max) { + System.println("[INFO] Max Cadence Saved: " + pickedValue); + app.setMaxCadence(pickedValue); + } + + // Update the menu title to show new range + if (_menu != null) { + var newMin = app.getMinCadence(); + var newMax = app.getMaxCadence(); + var newTitle = Lang.format("Cadence: $1$ - $2$", [newMin, newMax]); + _menu.setTitle(newTitle); + System.println("[DEBUG] Menu title updated to: " + newTitle); + } + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } + + function onCancel() as Boolean { + System.println("[DEBUG] CadenceRangePickerDelegate onCancel called"); + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\SelectCadenceDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; +import Toybox.Graphics; + +class SelectCadenceDelegate extends WatchUi.Menu2InputDelegate { + + private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; + + function initialize(menu as WatchUi.Menu2) { + Menu2InputDelegate.initialize(); + _menu = menu; + } + + function onSelect(item) as Void { + var id = item.getId(); + + System.println("[DEBUG] SelectCadenceDelegate onSelect called with id: " + id); + + // Show picker for min or max cadence + if (id == :item_set_min) { + System.println("[DEBUG] Opening minCadencePicker"); + minCadencePicker(); + } + else if (id == :item_set_max) { + System.println("[DEBUG] Opening maxCadencePicker"); + maxCadencePicker(); + } + else { + System.println("[DEBUG] Unknown menu item id: " + id); + } + } + + function onMenuItem(item as Symbol) as Void { + System.println("[DEBUG] onMenuItem called with: " + item); + // Legacy code - no longer used with pickers + // Keeping for backwards compatibility if needed + } + + // Returns back one menu + function onBack() as Void { + System.println("[DEBUG] SelectCadenceDelegate onBack called"); + WatchUi.popView(WatchUi.SLIDE_BLINK); + } + + function minCadencePicker() as Void { + System.println("[DEBUG] minCadencePicker() started"); + + var currentMin = app.getMinCadence(); + if (currentMin == null) { currentMin = 120; } // Default 120 spm + + System.println("[DEBUG] Current min cadence: " + currentMin); + + try { + // Range: 50-200, increment by 1, label " spm" + var factory = new ProfilePickerFactory(50, 200, 1, {:label=>" spm"}); + System.println("[DEBUG] ProfilePickerFactory created"); + + var picker = new WatchUi.Picker({ + :title => new WatchUi.Text({ + :text=>"Min Cadence", + :locX=>WatchUi.LAYOUT_HALIGN_CENTER, + :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, + :color=>Graphics.COLOR_WHITE + }), + :pattern => [factory], + :defaults => [factory.getIndex(currentMin)] + }); + System.println("[DEBUG] Picker created"); + + WatchUi.pushView(picker, new CadenceRangePickerDelegate(:cadence_min, _menu), WatchUi.SLIDE_LEFT); + System.println("[DEBUG] Picker pushed to view"); + } + catch (ex) { + System.println("[ERROR] Exception in minCadencePicker: " + ex.getErrorMessage()); + } + } + + function maxCadencePicker() as Void { + System.println("[DEBUG] maxCadencePicker() started"); + + var currentMax = app.getMaxCadence(); + if (currentMax == null) { currentMax = 150; } // Default 150 spm + + System.println("[DEBUG] Current max cadence: " + currentMax); + + try { + // Range: 50-200, increment by 1, label " spm" + var factory = new ProfilePickerFactory(50, 200, 1, {:label=>" spm"}); + System.println("[DEBUG] ProfilePickerFactory created"); + + var picker = new WatchUi.Picker({ + :title => new WatchUi.Text({ + :text=>"Max Cadence", + :locX=>WatchUi.LAYOUT_HALIGN_CENTER, + :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, + :color=>Graphics.COLOR_WHITE + }), + :pattern => [factory], + :defaults => [factory.getIndex(currentMax)] + }); + System.println("[DEBUG] Picker created"); + + WatchUi.pushView(picker, new CadenceRangePickerDelegate(:cadence_max, _menu), WatchUi.SLIDE_LEFT); + System.println("[DEBUG] Picker pushed to view"); + } + catch (ex) { + System.println("[ERROR] Exception in maxCadencePicker: " + ex.getErrorMessage()); + } + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SettingsDelegates\SettingsDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +//this Delegate handels the menu items and creates the menus for each item +class SettingsMenuDelegate extends WatchUi.Menu2InputDelegate { + + function initialize() { + Menu2InputDelegate.initialize(); + } + + //triggers when user selects a menu option + function onSelect(item as WatchUi.MenuItem) as Void { + var id = item.getId(); + + //pushes next menu view based on selection + if (id == :set_profile) { + System.println("Selected: Set Profile"); + //function to push next view + pushProfileMenu(); + } + else if (id == :cust_options) { + System.println("Selected: Customizable Options"); + pushCustMenu(); + } + else if (id == :feedback_options) { + System.println("Selected: Feedback Options"); + pushFeedbackMenu(); + } + else if (id == :cadence_range) { + pushCadenceMenu(); + } + } + + //allows user to go back from the menu view + function onBack() as Void { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + + function pushProfileMenu() as Void{ + + //creates the secondary menu and sets title + var menu = new WatchUi.Menu2({ + :title => "Profile Options" + }); + + //creates the new menu items + menu.addItem(new WatchUi.MenuItem("Height", null, :profile_height, null)); + menu.addItem(new WatchUi.MenuItem("Speed", null, :profile_speed, null)); + menu.addItem(new WatchUi.MenuItem("Experience level", null, :profile_experience, null)); + menu.addItem(new WatchUi.MenuItem("Gender", null, :profile_gender, null)); + + //pushes the view to the screen with the relevent delegate + WatchUi.pushView(menu, new SelectProfileDelegate(menu), WatchUi.SLIDE_LEFT); + + } + + function pushCustMenu() as Void{ + + var menu = new WatchUi.Menu2({ + :title => "Customization Options" + }); + + menu.addItem(new WatchUi.MenuItem("Bar Chart", null, :cust_bar_chart, null)); + + WatchUi.pushView(menu, new SelectCutomizableDelegate(menu), WatchUi.SLIDE_LEFT); + + } + + function pushFeedbackMenu() as Void{ + + var menu = new WatchUi.Menu2({ + :title => "Feedback Options" + }); + + menu.addItem(new WatchUi.MenuItem("Haptic Feedback", null, :haptic_feedback, null)); + menu.addItem(new WatchUi.MenuItem("Audible Feedback", null, :audible_feedback, null)); + + WatchUi.pushView(menu, new SelectFeedbackDelegate(menu), WatchUi.SLIDE_LEFT); + } + + function pushCadenceMenu() as Void { + + //sets the cadence variables to the global app variable to be used within the title + var app = Application.getApp() as GarminApp; + var minCadence = app.getMinCadence(); + var maxCadence = app.getMaxCadence(); + + var menu = new WatchUi.Menu2({ + :title => Lang.format("Cadence: $1$ - $2$", [minCadence, maxCadence]) + }); + + menu.addItem(new WatchUi.MenuItem(WatchUi.loadResource(Rez.Strings.menu_inc_min), null, :item_inc_min, null)); + menu.addItem(new WatchUi.MenuItem(WatchUi.loadResource(Rez.Strings.menu_dec_min), null, :item_dec_min, null)); + menu.addItem(new WatchUi.MenuItem(WatchUi.loadResource(Rez.Strings.menu_inc_max), null, :item_inc_max, null)); + menu.addItem(new WatchUi.MenuItem(WatchUi.loadResource(Rez.Strings.menu_dec_max), null, :item_dec_max, null)); + + WatchUi.pushView(menu, new SelectCadenceDelegate(menu), WatchUi.SLIDE_LEFT); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\AdvancedViewDelegate.mc ===== + +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; +import Toybox.Application; + +class AdvancedViewDelegate extends WatchUi.BehaviorDelegate { + + private var _currentView = null; + + function initialize(view as AdvancedView) { + BehaviorDelegate.initialize(); + } + + function onMenu() as Boolean { + // Create programmatic Menu2 instead of XML-based menu + var app = Application.getApp() as GarminApp; + var minCadence = app.getMinCadence(); + var maxCadence = app.getMaxCadence(); + + var menu = new WatchUi.Menu2({ + :title => Lang.format("Cadence: $1$ - $2$", [minCadence, maxCadence]) + }); + + menu.addItem(new WatchUi.MenuItem("Set Min Cadence", null, :item_set_min, null)); + menu.addItem(new WatchUi.MenuItem("Set Max Cadence", null, :item_set_max, null)); + + WatchUi.pushView(menu, new SelectCadenceDelegate(menu), WatchUi.SLIDE_BLINK); + + return true; + } + + function onKey(keyEvent as WatchUi.KeyEvent) as Boolean { + var key = keyEvent.getKey(); + + // UP button - Back to SimpleView + if (key == WatchUi.KEY_UP) { + WatchUi.popView(WatchUi.SLIDE_UP); + return true; + } + + return false; + } + + function onSwipe(swipeEvent as WatchUi.SwipeEvent) as Boolean { + var direction = swipeEvent.getDirection(); + + // Swipe DOWN - Back to SimpleView + if (direction == WatchUi.SWIPE_DOWN) { + System.println("[UI] Swiped down to SimpleView"); + WatchUi.popView(WatchUi.SLIDE_UP); + return true; + } + + // Swipe LEFT - Settings + if (direction == WatchUi.SWIPE_LEFT) { + pushSettingsView(); + return true; + } + + return false; + } + + function onBack() as Boolean { + WatchUi.popView(WatchUi.SLIDE_BLINK); + return true; + } + + function pushSettingsView() as Void { + var settingsMenu = new WatchUi.Menu2({ :title => "Settings" }); + settingsMenu.addItem(new WatchUi.MenuItem("Profile", null, :set_profile, null)); + settingsMenu.addItem(new WatchUi.MenuItem("Customization", null, :cust_options, null)); + settingsMenu.addItem(new WatchUi.MenuItem("Feedback", null, :feedback_options, null)); + settingsMenu.addItem(new WatchUi.MenuItem("Cadence Range", null, :cadence_range, null)); + + WatchUi.pushView(settingsMenu, new SettingsMenuDelegate(), WatchUi.SLIDE_UP); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Delegates\SimpleViewDelegate.mc ===== + +import Toybox.Lang; +import Toybox.WatchUi; +import Toybox.System; + +class SimpleViewDelegate extends WatchUi.BehaviorDelegate { + + private var _currentView = null; + private var _initTime = null; + private var _menuActive = false; + + function initialize() { + BehaviorDelegate.initialize(); + _initTime = System.getTimer(); + } + + function onMenu() as Boolean { + pushSettingsView(); + return true; + } + + function onSelect() as Boolean { + System.println("[DEBUG] onSelect called, menuActive=" + _menuActive); + + if (_initTime != null && (System.getTimer() - _initTime) < 1000) { + System.println("[DEBUG] Ignoring onSelect during initialization"); + return false; + } + + if (_menuActive) { + System.println("[DEBUG] Menu active, letting menu delegate handle it"); + return false; + } + + var app = getApp(); + if (app == null) { + System.println("[DEBUG] App not ready"); + return false; + } + + System.println("[DEBUG] Handling START/STOP button press"); + return handleStartStopButton(); + } + + function handleStartStopButton() as Boolean { + var app = getApp(); + + if (app.isIdle()) { + app.startRecording(); + System.println("[UI] Activity started"); + WatchUi.requestUpdate(); + } + else if (app.isRecording()) { + _menuActive = true; + showActivityControlMenu(); + } + else if (app.isPaused()) { + _menuActive = true; + showPausedControlMenu(); + } + else if (app.isStopped()) { + _menuActive = true; + showSaveDiscardMenu(); + } + return true; + } + + function onKey(keyEvent as WatchUi.KeyEvent) as Boolean { + var key = keyEvent.getKey(); + + if (key == WatchUi.KEY_DOWN) { + _currentView = new AdvancedView(); + WatchUi.pushView( + _currentView, + new AdvancedViewDelegate(_currentView), + WatchUi.SLIDE_DOWN + ); + return true; + } + + if (key == WatchUi.KEY_UP) { + pushSettingsView(); + return true; + } + + return false; + } + + function onSwipe(event as WatchUi.SwipeEvent) as Boolean { + var direction = event.getDirection(); + + if (direction == WatchUi.SWIPE_UP) { + _currentView = new AdvancedView(); + WatchUi.pushView( + _currentView, + new AdvancedViewDelegate(_currentView), + WatchUi.SLIDE_DOWN + ); + return true; + } + + if (direction == WatchUi.SWIPE_LEFT) { + pushSettingsView(); + return true; + } + + return false; + } + + function showActivityControlMenu() as Void { + var menu = new WatchUi.Menu2({ :title => "Activity" }); + menu.addItem(new WatchUi.MenuItem("Resume", "Continue", :resume_activity, null)); + menu.addItem(new WatchUi.MenuItem("Pause", "Pause activity", :pause_activity, null)); + menu.addItem(new WatchUi.MenuItem("Stop", "Stop activity", :stop_activity, null)); + + WatchUi.pushView(menu, new ActivityControlMenuDelegate(self), WatchUi.SLIDE_UP); + } + + function showPausedControlMenu() as Void { + var menu = new WatchUi.Menu2({ :title => "Activity Paused" }); + menu.addItem(new WatchUi.MenuItem("Resume", "Continue", :resume_activity, null)); + menu.addItem(new WatchUi.MenuItem("Stop", "Stop activity", :stop_activity, null)); + + WatchUi.pushView(menu, new ActivityControlMenuDelegate(self), WatchUi.SLIDE_UP); + } + + function showSaveDiscardMenu() as Void { + var menu = new WatchUi.Menu2({ :title => "Save Activity?" }); + menu.addItem(new WatchUi.MenuItem("Save", "Save session", :save_session, null)); + menu.addItem(new WatchUi.MenuItem("Discard", "Discard session", :discard_session, null)); + + WatchUi.pushView(menu, new SaveDiscardMenuDelegate(self), WatchUi.SLIDE_UP); + } + + function pushSettingsView() as Void { + var settingsMenu = new WatchUi.Menu2({ :title => "Settings" }); + settingsMenu.addItem(new WatchUi.MenuItem("Profile", null, :set_profile, null)); + settingsMenu.addItem(new WatchUi.MenuItem("Customization", null, :cust_options, null)); + settingsMenu.addItem(new WatchUi.MenuItem("Feedback", null, :feedback_options, null)); + settingsMenu.addItem(new WatchUi.MenuItem("Cadence Range", null, :cadence_range, null)); + + WatchUi.pushView(settingsMenu, new SettingsMenuDelegate(), WatchUi.SLIDE_UP); + } + + function setMenuActive(active as Boolean) as Void { + _menuActive = active; + System.println("[DEBUG] Menu active state set to: " + active); + } + + function onBack() as Boolean { + var app = getApp(); + + if (app.isRecording() || app.isPaused() || app.isStopped()) { + System.println("[UI] Session active - use Stop to exit"); + return true; + } + + return false; + } +} + +class ActivityControlMenuDelegate extends WatchUi.Menu2InputDelegate { + + private var _parentDelegate; + + function initialize(parentDelegate) { + Menu2InputDelegate.initialize(); + _parentDelegate = parentDelegate; + } + + function onSelect(item as WatchUi.MenuItem) as Void { + var id = item.getId(); + var app = getApp(); + + System.println("[DEBUG] Menu item selected: " + id); + + if (id == :pause_activity) { + app.pauseRecording(); + System.println("[UI] Activity paused"); + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.requestUpdate(); + + } else if (id == :resume_activity) { + if (app.isPaused()) { + app.resumeRecording(); + System.println("[UI] Activity resumed"); + } + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.requestUpdate(); + + } else if (id == :stop_activity) { + app.stopRecording(); + System.println("[UI] Activity stopped"); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + + var menu = new WatchUi.Menu2({ :title => "Save Activity?" }); + menu.addItem(new WatchUi.MenuItem("Save", "Save session", :save_session, null)); + menu.addItem(new WatchUi.MenuItem("Discard", "Discard session", :discard_session, null)); + WatchUi.pushView(menu, new SaveDiscardMenuDelegate(_parentDelegate), WatchUi.SLIDE_UP); + } + } + + function onBack() as Void { + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } +} + +class SaveDiscardMenuDelegate extends WatchUi.Menu2InputDelegate { + + private var _parentDelegate; + + function initialize(parentDelegate) { + Menu2InputDelegate.initialize(); + _parentDelegate = parentDelegate; + } + + function onSelect(item as WatchUi.MenuItem) as Void { + var id = item.getId(); + var app = getApp(); + + System.println("[DEBUG] Save/Discard selected: " + id); + + if (id == :save_session) { + app.saveSession(); + System.println("[UI] Activity saved"); + _parentDelegate.setMenuActive(false); + + var confirmationMenu = new WatchUi.Menu2({ :title => "Activity Saved!" }); + confirmationMenu.addItem(new WatchUi.MenuItem("Done", null, :done, null)); + WatchUi.pushView(confirmationMenu, new ConfirmationDelegate(_parentDelegate), WatchUi.SLIDE_IMMEDIATE); + + } else if (id == :discard_session) { + app.discardSession(); + System.println("[UI] Activity discarded"); + _parentDelegate.setMenuActive(false); + + var confirmationMenu = new WatchUi.Menu2({ :title => "Activity Discarded" }); + confirmationMenu.addItem(new WatchUi.MenuItem("Done", null, :done, null)); + WatchUi.pushView(confirmationMenu, new ConfirmationDelegate(_parentDelegate), WatchUi.SLIDE_IMMEDIATE); + } + + WatchUi.requestUpdate(); + } + + function onBack() as Void { + } +} + +class ConfirmationDelegate extends WatchUi.Menu2InputDelegate { + + private var _parentDelegate; + + function initialize(parentDelegate) { + Menu2InputDelegate.initialize(); + _parentDelegate = parentDelegate; + } + + function onSelect(item as WatchUi.MenuItem) as Void { + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } + + function onBack() as Void { + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Views\AdvancedView.mc ===== + +import Toybox.Graphics; +import Toybox.WatchUi; +import Toybox.Activity; +import Toybox.Lang; +import Toybox.Timer; +import Toybox.System; + +class AdvancedView extends WatchUi.View { + const MAX_BARS = 280; + const MAX_CADENCE_DISPLAY = 200; + + private var _simulationTimer; + + function initialize() { + View.initialize(); + } + + function onShow() as Void { + _simulationTimer = new Timer.Timer(); + _simulationTimer.start(method(:refreshScreen), 1000, true); + } + + function onHide() as Void { + if (_simulationTimer != null) { + _simulationTimer.stop(); + _simulationTimer = null; + } + } + + function onUpdate(dc as Dc) as Void { + View.onUpdate(dc); + // Draw all the elements + drawElements(dc); + } + + function refreshScreen() as Void { + WatchUi.requestUpdate(); + } + + + + function drawElements(dc as Dc) as Void { + var width = dc.getWidth(); + var height = dc.getHeight(); + var info = Activity.getActivityInfo(); + var app = getApp(); + + // Draw elapsed time at top (yellow RGB: 255,248,18 = 0xFFF, using picker in paint to get RGB then convert to hex + if (info != null && info.timerTime != null) { + var seconds = info.timerTime / 1000; + var hours = seconds / 3600; + var minutes = (seconds % 3600) / 60; + //var secs = seconds % 60; + var timeStr = hours.format("%01d") + ":" + minutes.format("%02d"); //+ "." + secs.format("%02d"); + dc.setColor(0xFFF813, Graphics.COLOR_TRANSPARENT); + dc.drawText(width / 2, 3, Graphics.FONT_LARGE, timeStr, Graphics.TEXT_JUSTIFY_CENTER); + } + + // Draw heart rate circle (left, dark red RGB: 211,19,2519 + var hrX = width / 4; + var hrY = (height * 2) / 7; + var circleRadius = 42; + + dc.setColor(0x9D0000, Graphics.COLOR_TRANSPARENT); + dc.fillCircle(hrX, hrY, circleRadius); + + if (info != null && info.currentHeartRate != null) { + dc.setColor(0xFFFFFF, Graphics.COLOR_TRANSPARENT); // White RGB: 255,255,255 + dc.drawText(hrX, hrY - 25, Graphics.FONT_TINY, info.currentHeartRate.toString(), Graphics.TEXT_JUSTIFY_CENTER); + dc.drawText(hrX, hrY + 8, Graphics.FONT_XTINY, "bpm", Graphics.TEXT_JUSTIFY_CENTER); + } + + // Draw distance circle (right, dark green RGB: 24,19,24 = 0x1D5E11) + var distX = (width * 3) / 4; + var distY = hrY; + + dc.setColor(0x1D5E11, Graphics.COLOR_TRANSPARENT); + dc.fillCircle(distX, distY, circleRadius); + + if (info != null && info.elapsedDistance != null) { + var distanceKm = info.elapsedDistance / 100000.0; + dc.setColor(0xFFFFFF, Graphics.COLOR_TRANSPARENT); // White RGB: 255,255,255 + dc.drawText(distX, distY - 25, Graphics.FONT_TINY, distanceKm.format("%.2f"), Graphics.TEXT_JUSTIFY_CENTER); + dc.drawText(distX, distY + 8, Graphics.FONT_XTINY, "km", Graphics.TEXT_JUSTIFY_CENTER); + } + + //draw ideal cadence range + + var idealMinCadence = app.getMinCadence(); + var idealMaxCadence = app.getMaxCadence(); + + var cadenceY = height * 0.37; + var chartDurationY = height * 0.85; + + if (info != null && info.currentCadence != null) { + // Draw cadence value in green (RGB: 0,255,0 = 0x00FF00) + correctColor(info.currentCadence, idealMinCadence, idealMaxCadence, dc); + dc.drawText(width / 2, cadenceY + 20, Graphics.FONT_XTINY, info.currentCadence.toString() + " spm", Graphics.TEXT_JUSTIFY_CENTER); + } + + drawChart(dc); + + // Display cadence zone range instead of time duration + var minZone = app.getMinCadence(); + var maxZone = app.getMaxCadence(); + var zoneText = "Zone: " + minZone.toString() + "-" + maxZone.toString() + " spm"; + + dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT); + dc.drawText(width / 2, chartDurationY, Graphics.FONT_XTINY, zoneText, Graphics.TEXT_JUSTIFY_CENTER); + + } + + + + /** + Functions to continous update the chart with live cadence data. + The chart is split into bars each representing a candence reading, + Each bar data is retrieve from an cadencecadence array which is updated every tick + Each update the watchUI redraws the chart with the latest data. + } + **/ + + function drawChart(dc as Dc) as Void { + var width = dc.getWidth(); + var height = dc.getHeight(); + + //margins value + var margin = width * 0.1; + var marginLeftRightMultiplier = 1.38; + var marginBottomMultiplier = 1.6; + + //chart position + var chartLeft = margin * marginLeftRightMultiplier; + var chartRight = width - chartLeft; + var chartTop = height * 0.5; + var chartBottom = height - margin*marginBottomMultiplier; + var chartWidth = chartRight - chartLeft; + var chartHeight = chartBottom - chartTop; + var quarterChartHeight = chartHeight / 4; + + //bar zone + var barZoneLeft = chartLeft + 1; + var barZoneRight = chartRight - 1; + var barZoneWidth = barZoneRight - barZoneLeft; + var barZoneBottom = chartBottom - 1; + + //additional line indicator + var nLine = 3; + var lineLength = 6; + var line1x1 = chartLeft - lineLength; + var line1x2 = chartLeft; + var line2x1 = chartRight - 1; + var line2x2 = chartRight + lineLength; + var lineY = chartTop + quarterChartHeight; + + // Draw white border around chart + dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT); + dc.drawRectangle(chartLeft, chartTop, chartWidth, chartHeight); + for(var i = 0; i < nLine; i++){ + dc.drawLine(line1x1,lineY,line1x2,lineY); + dc.drawLine(line2x1,lineY,line2x2,lineY); + lineY += quarterChartHeight; + } + + // Get data from app + var app = getApp(); + var idealMinCadence = app.getMinCadence(); + var idealMaxCadence = app.getMaxCadence(); + var cadenceHistory = app.getCadenceHistory(); + + var cadenceIndex = app.getCadenceIndex(); + var cadenceCount = app.getCadenceCount(); + + if(cadenceCount == 0) {return;} + + var numBars = cadenceCount; + var barWidth = (barZoneWidth / MAX_BARS).toNumber(); + var startIndex = (cadenceIndex - numBars + MAX_BARS) % MAX_BARS; + + var colorThreshold = 20; + + // FIXED SCALE - bars have fixed height based on absolute cadence + // Colors change dynamically based on your zone + for (var i = 0; i < numBars; i++) { + var index = (startIndex + i) % MAX_BARS; + var cadence = cadenceHistory[index]; + if(cadence == null) {cadence = 0;} + + // Fixed bar height - same cadence always same height + var barHeight = ((cadence / MAX_CADENCE_DISPLAY) * chartHeight).toNumber(); + var x = barZoneLeft + i * barWidth; + var y = barZoneBottom - barHeight; + + // Dynamic color based on YOUR current zone + //FML + if (cadence < idealMinCadence - colorThreshold) { + // Way below zone - Grey + dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT); + } + else if (cadence >= idealMinCadence - colorThreshold && cadence < idealMinCadence) { + // Below zone - Blue + dc.setColor(0x0cc0df, Graphics.COLOR_TRANSPARENT); + } + else if (cadence >= idealMinCadence && cadence <= idealMaxCadence) { + // In zone - Green (YOUR ZONE!) + dc.setColor(0x00bf63, Graphics.COLOR_TRANSPARENT); + } + else if (cadence > idealMaxCadence && cadence <= idealMaxCadence + colorThreshold) { + // Above zone - Orange + dc.setColor(0xff751f, Graphics.COLOR_TRANSPARENT); + } + else if (cadence > idealMaxCadence + colorThreshold) { + // Way above zone - Red + dc.setColor(0xFF0000, Graphics.COLOR_TRANSPARENT); + } + + dc.fillRectangle(x, y, barWidth, barHeight); + } + } + + + function correctColor(cadence as Number, idealMinCadence as Number, idealMaxCadence as Number, dc as Dc) as Void{ + var colorThreshold = 20; + + if(cadence < idealMinCadence) + { + if(cadence > idealMinCadence - colorThreshold){ + dc.setColor(0x0cc0df, Graphics.COLOR_TRANSPARENT); //blue + } + else{ + dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT);//grey + } + + } + else if (cadence > idealMaxCadence) + { + if(cadence < idealMaxCadence + colorThreshold){ + dc.setColor(0xff751f, Graphics.COLOR_TRANSPARENT);//orange + } + else{ + dc.setColor(0xFF0000, Graphics.COLOR_TRANSPARENT);//red + } + } + else + { + dc.setColor(0x00bf63, Graphics.COLOR_TRANSPARENT);//green + } + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Views\SettingsView.mc ===== + +import Toybox.Graphics; +import Toybox.WatchUi; +import Toybox.System; +import Toybox.Application; +import Toybox.Lang; +import Toybox.Math; + +class SettingsView extends WatchUi.View { + + // to store the coords and width/heigh of the button (for cadence for now) + private var _buttonCoords as Array?; + + + function initialize() { + View.initialize(); + _buttonCoords = [0, 0, 0, 0] as Array; + } + + + function onLayout(dc as Dc) as Void { + + // Define button dimensions based on screen size (rough values for now) + var screenWidth = dc.getWidth(); + var screenHeight = dc.getHeight(); + + var x1 = screenWidth * 0.2; + var y1 = screenHeight / 2; + var width = screenWidth - (screenWidth * 0.4); + var height = screenHeight / 3; + + // Sets button coords + _buttonCoords = [x1, y1, width, height] as Array; + System.println(x1.toString() + " and " + y1.toString() + " and " + width.toString() + " and " + height.toString()); + + } + + function onShow() as Void {} + + function onUpdate(dc as Dc) as Void { + + View.onUpdate(dc); + drawCadenceButton(dc); + + } + + // Draws the temp button + function drawCadenceButton(dc as Dc) as Void { + + dc.setColor(Graphics.COLOR_BLUE, Graphics.COLOR_BLUE); + dc.drawRoundedRectangle(_buttonCoords[0], _buttonCoords[1], _buttonCoords[2], _buttonCoords[3], 10); + + } + + // Public getter method for the button coordinates + function getButtonCoords() as Array { + return _buttonCoords; + } + + function refreshScreen() as Void{ + WatchUi.requestUpdate(); + } + + function onHide() as Void {} +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Views\SimpleView.mc ===== + +import Toybox.Graphics; +import Toybox.WatchUi; +import Toybox.Activity; +import Toybox.Lang; +import Toybox.Timer; +import Toybox.System; + +class SimpleView extends WatchUi.View { + + private var _cadenceDisplay; + private var _refreshTimer; + private var _heartrateDisplay; + private var _distanceDisplay; + private var _timeDisplay; + private var _cadenceZoneDisplay; + private var _lastZoneState = 0; // -1 = below, 0 = inside, 1 = above + private var _vibeTimer = new Timer.Timer(); + private var _cqDisplay; + private var _hardcoreDisplay; + + function _secondVibe() as Void { + // Haptics not available on this target SDK/device in this workspace. + // Replace the println below with the device vibration call when supported, + // e.g. `Haptics.vibrate(120)` or `System.vibrate(120)` on SDKs that provide it. + System.println("[vibe] second pulse"); + } + + function initialize() { + View.initialize(); + } + + // Load your resources here + function onLayout(dc as Dc) as Void { + setLayout(Rez.Layouts.MainLayout(dc)); + _cadenceDisplay = findDrawableById("cadence_text"); + _cadenceZoneDisplay = findDrawableById("cadence_zone"); + _heartrateDisplay = findDrawableById("heartrate_text"); + _distanceDisplay = findDrawableById("distance_text"); + _timeDisplay = findDrawableById("time_text"); + _cqDisplay = findDrawableById("cq_text"); + _hardcoreDisplay = findDrawableById("hardcore_text"); + + + } + + // 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 { + _refreshTimer = new Timer.Timer(); + _refreshTimer.start(method(:refreshScreen), 1000, true); + } + + // Update the view + function onUpdate(dc as Dc) as Void { + //update the display for current cadence + displayCadence(); + + // Draw recording indicator + drawRecordingIndicator(dc); + + // 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 { + if (_refreshTimer != null) { + _refreshTimer.stop(); + _refreshTimer = null; + } + } + + function refreshScreen() as Void{ + WatchUi.requestUpdate(); + } + + function drawRecordingIndicator(dc as Dc) as Void { + var app = getApp(); + + if (app.isActivityRecording()) { + // Draw a red recording indicator in top-right corner + dc.setColor(Graphics.COLOR_RED, Graphics.COLOR_TRANSPARENT); + var width = dc.getWidth(); + var radius = 8; + dc.fillCircle(width - 15, 15, radius); + + // Add "REC" text next to the indicator + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); + dc.drawText(width - 35, 5, Graphics.FONT_TINY, "REC", Graphics.TEXT_JUSTIFY_RIGHT); + } else { + // Draw instruction text at bottom + dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT); + var width = dc.getWidth(); + var height = dc.getHeight(); + dc.drawText(width / 2, height - 25, Graphics.FONT_TINY, "Press SELECT to start", Graphics.TEXT_JUSTIFY_CENTER); + } + } + + function displayCadence() as Void{ + var info = Activity.getActivityInfo(); + + + if (info != null && info.currentCadence != null){ + _cadenceDisplay.setText(info.currentCadence.toString()); + }else{ + _cadenceDisplay.setText("--"); + } + + // Show whether current cadence is inside configured zone + var minZone = getApp().getMinCadence(); + var maxZone = getApp().getMaxCadence(); + var zoneText = ""; + if (info != null && info.currentCadence != null) { + var c = info.currentCadence; + if (c >= minZone && c <= maxZone) { + zoneText = (WatchUi.loadResource(Rez.Strings.zone_in) as String) + " (" + minZone.toString() + "-" + maxZone.toString() + ")"; + } else { + zoneText = (WatchUi.loadResource(Rez.Strings.zone_out) as String) + " (" + minZone.toString() + "-" + maxZone.toString() + ")"; + } + } else { + zoneText = "(" + minZone.toString() + "-" + maxZone.toString() + ")"; + } + if (_cadenceZoneDisplay != null) { + _cadenceZoneDisplay.setText(zoneText); + } + + // Trigger haptic on zone crossing: single when falling below min, double when going above max + var newZoneState = 0; + if (info != null && info.currentCadence != null) { + var c = info.currentCadence; + if (c < minZone) { + newZoneState = -1; + } else if (c > maxZone) { + newZoneState = 1; + } else { + newZoneState = 0; + } + } + + if (newZoneState != _lastZoneState) { + if (newZoneState == -1) { + // single short vibration + // single pulse (placeholder) + System.println("[vibe] single pulse (below min)"); + } else if (newZoneState == 1) { + // double short vibration: second pulse scheduled + // first pulse (placeholder) + System.println("[vibe] first pulse (above max)"); + _vibeTimer.start(method(:_secondVibe), 240, false); + } + _lastZoneState = newZoneState; + } + + if (info != null && info.currentHeartRate != null){ + _heartrateDisplay.setText(info.currentHeartRate.toString()); + }else{ + _heartrateDisplay.setText("--"); + } + + // Display distance in kilometers with 2 decimal places + if (info != null && info.elapsedDistance != null){ + var distanceKm = info.elapsedDistance / 100000.0; // Convert centimeters to kilometers + _distanceDisplay.setText(distanceKm.format("%.2f") + " km"); + }else{ + _distanceDisplay.setText("-- km"); + } + + // Display elapsed time in HH:MM:SS format + if (info != null && info.timerTime != null){ + var seconds = info.timerTime / 1000; // Convert milliseconds to seconds + var hours = seconds / 3600; + var minutes = (seconds % 3600) / 60; + var secs = seconds % 60; + _timeDisplay.setText(hours.format("%02d") + ":" + minutes.format("%02d") + ":" + secs.format("%02d")); + }else{ + _timeDisplay.setText("--:--:--"); + } + + /// --- Cadence Quality (Easter Egg) --- + if (_cqDisplay != null) { + var app = getApp(); + var frozenCQ = app.getFinalCadenceQuality(); + + if (frozenCQ != null) { + _cqDisplay.setText("CQ: " + frozenCQ.format("%d") + "%"); + } else { + var cq = app.computeCadenceQualityScore(); + + if (cq < 0) { + _cqDisplay.setText("CQ: --"); + } else { + _cqDisplay.setText("CQ: " + cq.format("%d") + "%"); + } + } + } + + + } + +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\GarminApp.mc ===== + +import Toybox.Application; +import Toybox.Lang; +import Toybox.WatchUi; +import Toybox.Timer; +import Toybox.Activity; +import Toybox.ActivityRecording; +import Toybox.System; + + +class GarminApp extends Application.AppBase { + const MAX_BARS = 280; + const BASELINE_AVG_CADENCE = 160; + const MAX_CADENCE = 190; + const MIN_CQ_SAMPLES = 30; + const DEBUG_MODE = true; + + var globalTimer; + var activitySession; // Garmin activity recording session + + enum SessionState { + IDLE, + RECORDING, + PAUSED, + STOPPED + } + + private var _sessionState as SessionState = IDLE; + + enum { + FifteenminChart = 3, + ThirtyminChart = 6, + OneHourChart = 13, + TwoHourChart = 26 + } + + const CHART_ENUM_NAMES = { + FifteenminChart => "15 Minutes", + ThirtyminChart => "30 Minutes", + OneHourChart => "1 Hour", + TwoHourChart => "2 Hours" + }; + + enum { + Beginner = 1.06, + Intermediate = 1.04, + Advanced = 1.02 + } + + enum { + Male, + Female, + Other + } + + private var _userHeight = 170; + private var _userSpeed = 10; + private var _experienceLvl = Beginner; + private var _userGender = Male; + private var _chartDuration = ThirtyminChart as Number; + + private var _idealMinCadence = 120; + private var _idealMaxCadence = 150; + + private var _cadenceHistory as Array = new [MAX_BARS]; + private var _cadenceIndex = 0; + private var _cadenceCount = 0; + + private var _cadenceBarAvg as Array = new [_chartDuration]; + private var _cadenceAvgIndex = 0; + private var _cadenceAvgCount = 0; + + private var _finalCQ = null; + private var _missingCadenceCount = 0; + private var _finalCQConfidence = null; + private var _finalCQTrend = null; + private var _cqHistory as Array = []; + + private var _sessionStartTime = null; + private var _sessionPausedTime = 0; + private var _lastPauseTime = null; + + + function initialize() { + AppBase.initialize(); + System.println("[INFO] App initialized"); + activitySession = null; + } + + function onStart(state as Dictionary?) as Void { + System.println("[INFO] App starting"); + Logger.logMemoryStats("Startup"); + + globalTimer = new Timer.Timer(); + globalTimer.start(method(:updateCadenceBarAvg),1000,true); + } + + function onStop(state as Dictionary?) as Void { + System.println("[INFO] App stopping"); + + // Stop any active session + if (activitySession != null && activitySession.isRecording()) { + activitySession.stop(); + activitySession = null; + } + + if(globalTimer != null){ + globalTimer.stop(); + globalTimer = null; + } + + Logger.logMemoryStats("Shutdown"); + } + + function startRecording() as Void { + if (_sessionState == RECORDING) { + System.println("[INFO] Already recording"); + return; + } + + System.println("[INFO] Starting activity session"); + + // Create and start Garmin activity session + activitySession = ActivityRecording.createSession({ + :name => "Running", + :sport => ActivityRecording.SPORT_RUNNING, + :subSport => ActivityRecording.SUB_SPORT_GENERIC + }); + + activitySession.start(); + System.println("[INFO] Garmin activity session started"); + + // Reset cadence monitoring data + _finalCQ = null; + _finalCQConfidence = null; + _finalCQTrend = null; + _cqHistory = []; + _cadenceCount = 0; + _cadenceIndex = 0; + _cadenceAvgCount = 0; + _cadenceAvgIndex = 0; + _missingCadenceCount = 0; + _sessionStartTime = System.getTimer(); + _sessionPausedTime = 0; + _lastPauseTime = null; + + for (var i = 0; i < MAX_BARS; i++) { + _cadenceHistory[i] = null; + } + for (var i = 0; i < _chartDuration; i++) { + _cadenceBarAvg[i] = null; + } + + _sessionState = RECORDING; + System.println("[INFO] Starting cadence monitoring"); + } + + function pauseRecording() as Void { + if (_sessionState != RECORDING) { + System.println("[INFO] Cannot pause - not recording"); + return; + } + + System.println("[INFO] Pausing activity session"); + + // Pause Garmin activity session + if (activitySession != null && activitySession.isRecording()) { + activitySession.stop(); + System.println("[INFO] Garmin activity session paused"); + } + + _lastPauseTime = System.getTimer(); + _sessionState = PAUSED; + } + + function resumeRecording() as Void { + if (_sessionState != PAUSED) { + System.println("[INFO] Cannot resume - not paused"); + return; + } + + System.println("[INFO] Resuming activity session"); + + // Resume Garmin activity session + if (activitySession != null && !activitySession.isRecording()) { + activitySession.start(); + System.println("[INFO] Garmin activity session resumed"); + } + + if (_lastPauseTime != null) { + _sessionPausedTime += System.getTimer() - _lastPauseTime; + _lastPauseTime = null; + } + + _sessionState = RECORDING; + } + + function stopRecording() as Void { + if (_sessionState == IDLE || _sessionState == STOPPED) { + System.println("[INFO] No active session to stop"); + return; + } + + System.println("[INFO] Stopping activity session"); + + // Stop Garmin activity session (but don't save or discard yet) + if (activitySession != null && activitySession.isRecording()) { + activitySession.stop(); + System.println("[INFO] Garmin activity session stopped"); + } + + if (_sessionState == PAUSED && _lastPauseTime != null) { + _sessionPausedTime += System.getTimer() - _lastPauseTime; + _lastPauseTime = null; + } + + var cq = computeCadenceQualityScore(); + + if (cq >= 0) { + _finalCQ = cq; + _finalCQConfidence = computeCQConfidence(); + _finalCQTrend = computeCQTrend(); + + System.println( + "[CADENCE QUALITY] Final CQ frozen at " + + cq.format("%d") + "% (" + + _finalCQTrend + ", " + + _finalCQConfidence + " confidence)" + ); + + writeDiagnosticLog(); + } + + _sessionState = STOPPED; + } + + function saveSession() as Void { + if (_sessionState != STOPPED) { + System.println("[INFO] Cannot save - session not stopped"); + return; + } + + System.println("[INFO] Saving activity session"); + + // Save Garmin activity session + if (activitySession != null) { + activitySession.save(); + System.println("[INFO] Garmin activity session saved to FIT file"); + activitySession = null; + } + + var totalTime = 0; + if (_sessionStartTime != null) { + totalTime = System.getTimer() - _sessionStartTime - _sessionPausedTime; + } + + System.println("===== SESSION SAVED ====="); + System.println("Duration: " + (totalTime / 1000).format("%d") + " seconds"); + System.println("Cadence samples: " + _cadenceCount.toString()); + System.println("Final CQ: " + (_finalCQ != null ? _finalCQ.format("%d") + "%" : "N/A")); + System.println("========================"); + + resetSession(); + } + + function discardSession() as Void { + if (_sessionState != STOPPED) { + System.println("[INFO] Cannot discard - session not stopped"); + return; + } + + System.println("[INFO] Discarding activity session"); + + // Discard Garmin activity session + if (activitySession != null) { + activitySession.discard(); + System.println("[INFO] Garmin activity session discarded"); + activitySession = null; + } + + resetSession(); + } + + function resetSession() as Void { + System.println("[INFO] Resetting session"); + + _sessionState = IDLE; + _finalCQ = null; + _finalCQConfidence = null; + _finalCQTrend = null; + _cqHistory = []; + _cadenceCount = 0; + _cadenceIndex = 0; + _cadenceAvgCount = 0; + _cadenceAvgIndex = 0; + _missingCadenceCount = 0; + _sessionStartTime = null; + _sessionPausedTime = 0; + _lastPauseTime = null; + + for (var i = 0; i < MAX_BARS; i++) { + _cadenceHistory[i] = null; + } + for (var i = 0; i < _chartDuration; i++) { + _cadenceBarAvg[i] = null; + } + } + + function updateCadenceBarAvg() as Void { + // CRITICAL: Only collect data when RECORDING + if (_sessionState != RECORDING) { + return; + } + + var info = Activity.getActivityInfo(); + + if (info != null && info.currentCadence != null) { + var newCadence = info.currentCadence; + _cadenceBarAvg[_cadenceAvgIndex] = newCadence.toFloat(); + _cadenceAvgIndex = (_cadenceAvgIndex + 1) % _chartDuration; + if (_cadenceAvgCount < _chartDuration) { + _cadenceAvgCount++; + } + else { + var barAvg = 0.0; + for(var i = 0; i < _chartDuration; i++){ + barAvg += _cadenceBarAvg[i]; + } + updateCadenceHistory(barAvg / _chartDuration); + _cadenceAvgCount = 0; + } + } + } + + function updateCadenceHistory(newCadence as Float) as Void { + _cadenceHistory[_cadenceIndex] = newCadence; + _cadenceIndex = (_cadenceIndex + 1) % MAX_BARS; + if (_cadenceCount < MAX_BARS) { _cadenceCount++; } + + if (DEBUG_MODE) { + System.println("[CADENCE] " + newCadence); + } + else { + _missingCadenceCount++; + } + + var cq = computeCadenceQualityScore(); + + if (cq < 0) { + System.println( + "[CADENCE QUALITY] Warming up (" + + _cadenceCount.toString() + "/" + + MIN_CQ_SAMPLES.toString() + " samples)" + ); + } else { + if (DEBUG_MODE) { + System.println("[CADENCE QUALITY] CQ = " + cq.format("%d") + "%"); + } + + _cqHistory.add(cq); + + if (_cqHistory.size() > 10) { + _cqHistory.remove(0); + } + } + + if (_cadenceIndex % 60 == 0 && _cadenceIndex > 0) { + Logger.logMemoryStats("Runtime"); + } + } + + function computeTimeInZoneScore() as Number { + if (_cadenceCount < MIN_CQ_SAMPLES) { + return -1; + } + + var minZone = _idealMinCadence; + var maxZone = _idealMaxCadence; + + var inZoneCount = 0; + var validSamples = 0; + + for (var i = 0; i < MAX_BARS; i++) { + var c = _cadenceHistory[i]; + + if (c != null) { + validSamples++; + + if (c >= minZone && c <= maxZone) { + inZoneCount++; + } + } + } + + if (validSamples == 0) { + return -1; + } + + var ratio = inZoneCount.toFloat() / validSamples.toFloat(); + return (ratio * 100).toNumber(); + } + + function idealCadenceCalculator() as Void { + var referenceCadence = 0; + var finalCadence = 0; + var userLegLength = _userHeight * 0.53; + var userSpeedms = _userSpeed / 3.6; + + switch (_userGender) { + case Male: + referenceCadence = (-1.268 * userLegLength) + (3.471 * userSpeedms) + 261.378; + break; + case Female: + referenceCadence = (-1.190 * userLegLength) + (3.705 * userSpeedms) + 249.688; + break; + default: + referenceCadence = (-1.251 * userLegLength) + (3.665 * userSpeedms) + 254.858; + break; + } + + referenceCadence = referenceCadence * _experienceLvl; + referenceCadence = Math.round(referenceCadence); + finalCadence = max(BASELINE_AVG_CADENCE,min(referenceCadence,MAX_CADENCE)).toNumber(); + + _idealMaxCadence = finalCadence + 5; + _idealMinCadence = finalCadence - 5; + } + + function computeSmoothnessScore() as Number { + if (_cadenceCount < MIN_CQ_SAMPLES) { + return -1; + } + + var totalDiff = 0.0; + var diffCount = 0; + + for (var i = 1; i < MAX_BARS; i++) { + var prev = _cadenceHistory[i - 1]; + var curr = _cadenceHistory[i]; + + if (prev != null && curr != null) { + totalDiff += abs(curr - prev); + diffCount++; + } + } + + if (diffCount == 0) { + return -1; + } + + var avgDiff = totalDiff / diffCount; + var rawScore = 100 - (avgDiff * 10); + + if (rawScore < 0) { rawScore = 0; } + if (rawScore > 100) { rawScore = 100; } + + return rawScore; + } + + function computeCadenceQualityScore() as Number { + var timeInZone = computeTimeInZoneScore(); + var smoothness = computeSmoothnessScore(); + + if (timeInZone < 0 || smoothness < 0) { + return -1; + } + + var cq = (timeInZone * 0.7) + (smoothness * 0.3); + return cq.toNumber(); + } + + function computeCQConfidence() as String { + if (_cadenceCount < MIN_CQ_SAMPLES) { + return "Low"; + } + + var missingRatio = _missingCadenceCount.toFloat() / + (_cadenceCount + _missingCadenceCount).toFloat(); + + if (missingRatio > 0.2) { + return "Low"; + } else if (missingRatio > 0.1) { + return "Medium"; + } else { + return "High"; + } + } + + function computeCQTrend() as String { + if (_cqHistory.size() < 5) { + return "Stable"; + } + + var first = _cqHistory[0]; + var last = _cqHistory[_cqHistory.size() - 1]; + var delta = last - first; + + if (delta < -5) { + return "Declining"; + } else if (delta > 5) { + return "Improving"; + } else { + return "Stable"; + } + } + + function writeDiagnosticLog() as Void { + if (!DEBUG_MODE) { + return; + } + + System.println("===== DIAGNOSTIC RUN SUMMARY ====="); + System.println("Final CQ: " + + (_finalCQ != null ? _finalCQ.format("%d") + "%" : "N/A")); + System.println("CQ Confidence: " + + (_finalCQConfidence != null ? _finalCQConfidence : "N/A")); + System.println("CQ Trend: " + + (_finalCQTrend != null ? _finalCQTrend : "N/A")); + System.println("Cadence samples collected: " + _cadenceCount.toString()); + System.println("Missing cadence samples: " + _missingCadenceCount.toString()); + + var totalSamples = _cadenceCount + _missingCadenceCount; + if (totalSamples > 0) { + var validRatio = + (_cadenceCount.toFloat() / totalSamples.toFloat()) * 100; + System.println("Valid data ratio: " + validRatio.format("%d") + "%"); + } + + System.println("Ideal cadence range: " + + _idealMinCadence.toString() + "-" + + _idealMaxCadence.toString()); + System.println("===== END DIAGNOSTIC SUMMARY ====="); + } + + function getSessionState() as SessionState { + return _sessionState; + } + + function isRecording() as Boolean { + return _sessionState == RECORDING; + } + + function isPaused() as Boolean { + return _sessionState == PAUSED; + } + + function isStopped() as Boolean { + return _sessionState == STOPPED; + } + + function isIdle() as Boolean { + return _sessionState == IDLE; + } + + function isActivityRecording() as Boolean { + return _sessionState == RECORDING || _sessionState == PAUSED; + } + + function getMinCadence() as Number { + return _idealMinCadence; + } + + function getMaxCadence() as Number { + return _idealMaxCadence; + } + + function setMinCadence(value as Number) as Void { + _idealMinCadence = value; + } + + function setMaxCadence(value as Number) as Void { + _idealMaxCadence = value; + } + + function getCadenceHistory() as Array { + return _cadenceHistory; + } + + function getCadenceIndex() as Number { + return _cadenceIndex; + } + + function getCadenceCount() as Number { + return _cadenceCount; + } + + function setChartDuration(value as Number) as Void { + _chartDuration = value; + System.println(CHART_ENUM_NAMES[_chartDuration] + " selected."); + } + + function getChartDuration() as String{ + return CHART_ENUM_NAMES[_chartDuration]; + } + + function getUserGender() as String { + return _userGender; + } + + function setUserGender(value as Number) as Void { + _userGender = value; + } + + function getUserLegLength() as Float { + return _userHeight * 0.53; + } + + function setUserHeight(value as Number) as Void { + _userHeight = value; + } + + function getUserHeight() as Number { + return _userHeight; + } + + function getUserSpeed() as Float { + return _userSpeed; + } + + function setUserSpeed(value as Float) as Void { + _userSpeed = value; + } + + function getExperienceLvl() as Number { + return _experienceLvl; + } + + function setExperienceLvl(value as Float) as Void { + _experienceLvl = value; + } + + function min(a,b){ + return (a < b) ? a : b; + } + + function max(a,b){ + return (a > b) ? a : b; + } + + function abs(x) { + return (x < 0) ? -x : x; + } + + function getFinalCadenceQuality() { + return _finalCQ; + } + + function getFinalCQConfidence() { + return _finalCQConfidence; + } + + function getFinalCQTrend() { + return _finalCQTrend; + } + + function getSessionDuration() as Number { + if (_sessionStartTime == null) { + return 0; + } + + var currentTime = System.getTimer(); + var totalTime = currentTime - _sessionStartTime - _sessionPausedTime; + + if (_sessionState == PAUSED && _lastPauseTime != null) { + totalTime -= (currentTime - _lastPauseTime); + } + + return totalTime / 1000; + } + + function getInitialView() as [Views] or [Views, InputDelegates] { + return [ new SimpleView(), new SimpleViewDelegate() ]; + } +} + +function getApp() as GarminApp { + return Application.getApp() as GarminApp; +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\Logger.mc ===== + +import Toybox.Lang; +import Toybox.System; + +/** + * Simple logger for memory monitoring only + */ +module Logger { + + /** + * Log memory statistics + */ + function logMemoryStats(tag as String) as Void { + try { + var stats = System.getSystemStats(); + var usedMemory = stats.totalMemory - stats.freeMemory; + var memoryPercent = (usedMemory.toFloat() / stats.totalMemory.toFloat() * 100).toNumber(); + + System.println("[MEMORY] " + tag + ": " + usedMemory + "/" + stats.totalMemory + + " bytes (" + memoryPercent + "% used)"); + } catch (e) { + System.println("[ERROR] Failed to log memory stats: " + e.getErrorMessage()); + } + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\source\SensorManager.mc ===== + +using Toybox.Activity; +using Toybox.System; +using Toybox.Lang; + +class SensorManager { + + // ----------------------- + // Static configuration + // ----------------------- + + // Use simulator mode by default + static var useSimulator = true; + + // Simulated cadence value for testing + static var simulatedCadence = 0; + + // ----------------------- + // Public Methods + // ----------------------- + + // Set simulated cadence (for testing) + public static function setSimCadence(value) { + if (value == null || !(value instanceof Lang.Number)) { + System.println("[SensorManager] ERROR: simulated cadence must be a number"); + return; + } + + SensorManager.simulatedCadence = value; + System.println("[SensorManager] Simulated cadence set to: " + SensorManager.simulatedCadence.toString()); + } + + // Switch mode between simulator and real sensor + public static function setMode(simulator) { + // No strict type check needed + SensorManager.useSimulator = simulator ? true : false; + System.println("[SensorManager] Mode set to: " + (SensorManager.useSimulator ? "SIM" : "REAL")); + } + + // Get current cadence + public static function getCadence() { + var cadence = 0; + + if (SensorManager.useSimulator) { + cadence = SensorManager.simulatedCadence; + System.println("[SensorManager] Returning SIM cadence: " + cadence); + } else { + var info = Activity.getActivityInfo(); + if (info != null && info.currentCadence != null) { + cadence = info.currentCadence; + System.println("[SensorManager] Returning REAL cadence: " + cadence); + } else { + System.println("[SensorManager] REAL cadence unavailable, returning 0"); + } + } + + return cadence; + } +} + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\manifest.xml ===== + + + + + + + + + + + + + + + + + + + + + + + + + eng + + + + + + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\monkey.jungle ===== + +base.sourcePath = source +base.resourcePath = resources +project.manifest = manifest.xml + + + +===== FILE: C:\Users\teapot\Documents\Projects\GitHub\garmin-smartwatch\TestingCadence.prg.debug.xml ===== + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/menus/menu.xml b/resources/menus/menu.xml index ff76802..5d5b9a3 100644 --- a/resources/menus/menu.xml +++ b/resources/menus/menu.xml @@ -1,7 +1,5 @@ - - - - - - + + + + diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index 1e0ea8d..280764c 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -5,11 +5,18 @@ Item 1 Item 2 + + + Set Min Cadence + Set Max Cadence + + Increase Min Decrease Min Increase Max Decrease Max - ReseT Zones + + Reset Zones In Zone OutZone diff --git a/source/Delegates/AdvancedViewDelegate.mc b/source/Delegates/AdvancedViewDelegate.mc index a6ea95d..57b6b30 100644 --- a/source/Delegates/AdvancedViewDelegate.mc +++ b/source/Delegates/AdvancedViewDelegate.mc @@ -5,24 +5,31 @@ import Toybox.Application; class AdvancedViewDelegate extends WatchUi.BehaviorDelegate { - //private var _view as AdvancedView; + private var _currentView = null; function initialize(view as AdvancedView) { BehaviorDelegate.initialize(); - //_view = view; } - function onMenu(){ - //called by the timer after 1s hold - var menu = new WatchUi.Menu2({:resources => "menus/menu.xml"}); - - WatchUi.pushView(new Rez.Menus.MainMenu(), new SelectCadenceDelegate(menu), WatchUi.SLIDE_BLINK); - + function onMenu() as Boolean { + // Create programmatic Menu2 instead of XML-based menu + var app = Application.getApp() as GarminApp; + var minCadence = app.getMinCadence(); + var maxCadence = app.getMaxCadence(); + + var menu = new WatchUi.Menu2({ + :title => Lang.format("Cadence: $1$ - $2$", [minCadence, maxCadence]) + }); + + menu.addItem(new WatchUi.MenuItem("Set Min Cadence", null, :item_set_min, null)); + menu.addItem(new WatchUi.MenuItem("Set Max Cadence", null, :item_set_max, null)); + + WatchUi.pushView(menu, new SelectCadenceDelegate(menu), WatchUi.SLIDE_BLINK); + return true; - } - function onKey(keyEvent as WatchUi.KeyEvent){ + function onKey(keyEvent as WatchUi.KeyEvent) as Boolean { var key = keyEvent.getKey(); // Scroll down to SimpleView (completing the loop) @@ -34,26 +41,32 @@ class AdvancedViewDelegate extends WatchUi.BehaviorDelegate { ); return true; } - - // Up button still goes back - if(key == WatchUi.KEY_UP) { - WatchUi.popView(WatchUi.SLIDE_UP); + + // UP button - Back to SimpleView + if (key == WatchUi.KEY_UP) { + WatchUi.switchToView( + new SimpleView(), + new SimpleViewDelegate(), + WatchUi.SLIDE_UP + ); + return true; } - return true; + + return false; } - - function onSwipe(SwipeEvent as WatchUi.SwipeEvent){ - var direction = SwipeEvent.getDirection(); + function onSwipe(swipeEvent as WatchUi.SwipeEvent) as Boolean { + var direction = swipeEvent.getDirection(); - //swipe back to simpleView + // Swipe DOWN - Back to SimpleView if (direction == WatchUi.SWIPE_DOWN) { - System.println("Swiped Up"); + System.println("[UI] Swiped down to SimpleView"); WatchUi.popView(WatchUi.SLIDE_UP); return true; } - if(direction == WatchUi.SWIPE_LEFT){ + // Swipe LEFT - Settings + if (direction == WatchUi.SWIPE_LEFT) { pushSettingsView(); return true; } @@ -61,14 +74,13 @@ class AdvancedViewDelegate extends WatchUi.BehaviorDelegate { return false; } - function onBack(){ + function onBack() as Boolean { // Back button disabled - no input return true; } - function pushSettingsView() as Void{ + function pushSettingsView() as Void { var settingsMenu = new WatchUi.Menu2({ :title => "Settings" }); - settingsMenu.addItem(new WatchUi.MenuItem("Profile", null, :set_profile, null)); settingsMenu.addItem(new WatchUi.MenuItem("Customization", null, :cust_options, null)); settingsMenu.addItem(new WatchUi.MenuItem("Feedback", null, :feedback_options, null)); @@ -76,5 +88,4 @@ class AdvancedViewDelegate extends WatchUi.BehaviorDelegate { WatchUi.pushView(settingsMenu, new SettingsMenuDelegate(), WatchUi.SLIDE_UP); } - -} \ No newline at end of file +} diff --git a/source/Delegates/SettingsDelegates/CadenceRangePickerDelegate.mc b/source/Delegates/SettingsDelegates/CadenceRangePickerDelegate.mc new file mode 100644 index 0000000..64d40fd --- /dev/null +++ b/source/Delegates/SettingsDelegates/CadenceRangePickerDelegate.mc @@ -0,0 +1,53 @@ +import Toybox.WatchUi; +import Toybox.System; +import Toybox.Application; +import Toybox.Lang; + +class CadenceRangePickerDelegate extends WatchUi.PickerDelegate { + + private var _typeId; + private var _menu; + + function initialize(typeId, menu) { + PickerDelegate.initialize(); + _typeId = typeId; + _menu = menu; + System.println("[DEBUG] CadenceRangePickerDelegate initialized with typeId: " + typeId); + } + + function onAccept(values as Array) as Boolean { + System.println("[DEBUG] CadenceRangePickerDelegate onAccept called"); + + var pickedValue = values[0]; // Gets the "selected" value + System.println("[DEBUG] Picked value: " + pickedValue); + + var app = Application.getApp() as GarminApp; + + if (_typeId == :cadence_min) { + System.println("[INFO] Min Cadence Saved: " + pickedValue); + app.setMinCadence(pickedValue); + } + else if (_typeId == :cadence_max) { + System.println("[INFO] Max Cadence Saved: " + pickedValue); + app.setMaxCadence(pickedValue); + } + + // Update the menu title to show new range + if (_menu != null) { + var newMin = app.getMinCadence(); + var newMax = app.getMaxCadence(); + var newTitle = Lang.format("Cadence: $1$ - $2$", [newMin, newMax]); + _menu.setTitle(newTitle); + System.println("[DEBUG] Menu title updated to: " + newTitle); + } + + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } + + function onCancel() as Boolean { + System.println("[DEBUG] CadenceRangePickerDelegate onCancel called"); + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } +} diff --git a/source/Delegates/SettingsDelegates/SelectCadenceDelegate.mc b/source/Delegates/SettingsDelegates/SelectCadenceDelegate.mc index 01dd931..ea467e8 100644 --- a/source/Delegates/SettingsDelegates/SelectCadenceDelegate.mc +++ b/source/Delegates/SettingsDelegates/SelectCadenceDelegate.mc @@ -2,10 +2,12 @@ import Toybox.Lang; import Toybox.System; import Toybox.WatchUi; import Toybox.Application; +import Toybox.Graphics; class SelectCadenceDelegate extends WatchUi.Menu2InputDelegate { private var _menu as WatchUi.Menu2; + var app = Application.getApp() as GarminApp; function initialize(menu as WatchUi.Menu2) { Menu2InputDelegate.initialize(); @@ -13,62 +15,99 @@ class SelectCadenceDelegate extends WatchUi.Menu2InputDelegate { } function onSelect(item) as Void { - } + var id = item.getId(); + + System.println("[DEBUG] SelectCadenceDelegate onSelect called with id: " + id); + // Show picker for min or max cadence + if (id == :item_set_min) { + System.println("[DEBUG] Opening minCadencePicker"); + minCadencePicker(); + } + else if (id == :item_set_max) { + System.println("[DEBUG] Opening maxCadencePicker"); + maxCadencePicker(); + } + else { + System.println("[DEBUG] Unknown menu item id: " + id); + } + } function onMenuItem(item as Symbol) as Void { - //var id = item.getId(); - var app = Application.getApp() as GarminApp; + System.println("[DEBUG] onMenuItem called with: " + item); + // Legacy code - no longer used with pickers + // Keeping for backwards compatibility if needed + } + + // Returns back one menu + function onBack() as Void { + System.println("[DEBUG] SelectCadenceDelegate onBack called"); + WatchUi.popView(WatchUi.SLIDE_BLINK); + } + + function minCadencePicker() as Void { + System.println("[DEBUG] minCadencePicker() started"); + var currentMin = app.getMinCadence(); - var currentMax = app.getMaxCadence(); + if (currentMin == null) { currentMin = 120; } // Default 120 spm + + System.println("[DEBUG] Current min cadence: " + currentMin); + try { + // Range: 50-200, increment by 1, label " spm" + var factory = new ProfilePickerFactory(50, 200, 1, {:label=>" spm"}); + System.println("[DEBUG] ProfilePickerFactory created"); - //Try to change cadence range based off menu selection - if (item == :item_inc_min){ - var v = currentMin + 5; - if (v < currentMax){ - app.setMinCadence(v); - System.println("Cadence Min + 5 : " + v.toString()); - //WatchUi.popView(WatchUi.SLIDE_RIGHT); - } else {System.println("Cadence Min cannot be more than Cadence Max");} - } - else if (item == :item_dec_min){ - var v = currentMin - 5; - if (v > 0){ - app.setMinCadence(v); - System.println("Cadence Min - 5 : " + v.toString()); - //WatchUi.popView(WatchUi.SLIDE_RIGHT); - } else {System.println("Cadence cannot be negative");} - } - else if (item == :item_inc_max){ - // no upper bounds yet - var v = currentMax + 5; - app.setMaxCadence(v); - System.println("Cadence Max + 5 : " + v.toString()); - //WatchUi.popView(WatchUi.SLIDE_RIGHT); - } - else if (item == :item_dec_max){ - var v = currentMax - 5; - if (v > currentMin){ - app.setMaxCadence(v); - System.println("Cadence Max - 5 : " + v.toString()); - //WatchUi.popView(WatchUi.SLIDE_RIGHT); - } else {System.println("Cadence Max cannot be less than Cadence Min");} + var picker = new WatchUi.Picker({ + :title => new WatchUi.Text({ + :text=>"Min Cadence", + :locX=>WatchUi.LAYOUT_HALIGN_CENTER, + :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, + :color=>Graphics.COLOR_WHITE + }), + :pattern => [factory], + :defaults => [factory.getIndex(currentMin)] + }); + System.println("[DEBUG] Picker created"); + + WatchUi.pushView(picker, new CadenceRangePickerDelegate(:cadence_min, _menu), WatchUi.SLIDE_LEFT); + System.println("[DEBUG] Picker pushed to view"); + } + catch (ex) { + System.println("[ERROR] Exception in minCadencePicker: " + ex.getErrorMessage()); } + } - var newMin = app.getMinCadence(); - var newMax = app.getMaxCadence(); + function maxCadencePicker() as Void { + System.println("[DEBUG] maxCadencePicker() started"); - var newTitle = Lang.format("Cadence: $1$ - $2$", [newMin, newMax]); + var currentMax = app.getMaxCadence(); + if (currentMax == null) { currentMax = 150; } // Default 150 spm - //this updates the UI when the cadence is changed - _menu.setTitle(newTitle); - } + System.println("[DEBUG] Current max cadence: " + currentMax); - //function onMenuItem(item as Symbol) as Void {} + try { + // Range: 50-200, increment by 1, label " spm" + var factory = new ProfilePickerFactory(50, 200, 1, {:label=>" spm"}); + System.println("[DEBUG] ProfilePickerFactory created"); - //returns back one menu - function onBack() as Void { - WatchUi.popView(WatchUi.SLIDE_BLINK); + var picker = new WatchUi.Picker({ + :title => new WatchUi.Text({ + :text=>"Max Cadence", + :locX=>WatchUi.LAYOUT_HALIGN_CENTER, + :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM, + :color=>Graphics.COLOR_WHITE + }), + :pattern => [factory], + :defaults => [factory.getIndex(currentMax)] + }); + System.println("[DEBUG] Picker created"); + + WatchUi.pushView(picker, new CadenceRangePickerDelegate(:cadence_max, _menu), WatchUi.SLIDE_LEFT); + System.println("[DEBUG] Picker pushed to view"); + } + catch (ex) { + System.println("[ERROR] Exception in maxCadencePicker: " + ex.getErrorMessage()); + } } -} \ No newline at end of file +} diff --git a/source/Delegates/SimpleViewDelegate.mc b/source/Delegates/SimpleViewDelegate.mc index ed41885..30b7780 100644 --- a/source/Delegates/SimpleViewDelegate.mc +++ b/source/Delegates/SimpleViewDelegate.mc @@ -5,62 +5,83 @@ import Toybox.System; class SimpleViewDelegate extends WatchUi.BehaviorDelegate { private var _currentView = null; + private var _initTime = null; + private var _menuActive = false; function initialize() { BehaviorDelegate.initialize(); + _initTime = System.getTimer(); } - // Long-press MENU (optional settings) function onMenu() as Boolean { pushSettingsView(); return true; } - // SELECT toggles cadence monitoring function onSelect() as Boolean { + System.println("[DEBUG] onSelect called, menuActive=" + _menuActive); + + if (_initTime != null && (System.getTimer() - _initTime) < 1000) { + System.println("[DEBUG] Ignoring onSelect during initialization"); + return false; + } + + if (_menuActive) { + System.println("[DEBUG] Menu active, letting menu delegate handle it"); + return false; + } + var app = getApp(); + if (app == null) { + System.println("[DEBUG] App not ready"); + return false; + } + + System.println("[DEBUG] Handling START/STOP button press"); + return handleStartStopButton(); + } - if (app.isActivityRecording()) { - app.stopRecording(); - System.println("[UI] Cadence monitoring stopped"); - - // Auto-navigate to summary screen if we have valid data - if (app.hasValidSummaryData()) { - System.println("[UI] Showing activity summary"); - var summaryView = new SummaryView(); - WatchUi.pushView( - summaryView, - new SummaryViewDelegate(), - WatchUi.SLIDE_UP - ); - } - } else { + function handleStartStopButton() as Boolean { + var app = getApp(); + + if (app.isIdle()) { app.startRecording(); - System.println("[UI] Cadence monitoring started"); + System.println("[UI] Activity started"); + WatchUi.requestUpdate(); + } + else if (app.isRecording()) { + _menuActive = true; + showActivityControlMenu(); + } + else if (app.isPaused()) { + _menuActive = true; + showPausedControlMenu(); + } + else if (app.isStopped()) { + _menuActive = true; + showSaveDiscardMenu(); } - - WatchUi.requestUpdate(); return true; } function onKey(keyEvent as WatchUi.KeyEvent) as Boolean { var key = keyEvent.getKey(); - // Block Garmin system menu - if (key == WatchUi.KEY_UP) { - return true; - } - if (key == WatchUi.KEY_DOWN) { - _currentView = new TimeView(); - WatchUi.switchToView( + _currentView = new AdvancedView(); + WatchUi.pushView( _currentView, - new TimeViewDelegate(), + new AdvancedViewDelegate(_currentView), WatchUi.SLIDE_DOWN ); return true; } + if (key == WatchUi.KEY_UP) { + pushSettingsView(); + return true; + } + return false; } @@ -85,9 +106,33 @@ class SimpleViewDelegate extends WatchUi.BehaviorDelegate { return false; } - function pushSettingsView() as Void{ - var settingsMenu = new WatchUi.Menu2({ :title => "Settings" }); + function showActivityControlMenu() as Void { + var menu = new WatchUi.Menu2({ :title => "Activity" }); + menu.addItem(new WatchUi.MenuItem("Resume", "Continue", :resume_activity, null)); + menu.addItem(new WatchUi.MenuItem("Pause", "Pause activity", :pause_activity, null)); + menu.addItem(new WatchUi.MenuItem("Stop", "Stop activity", :stop_activity, null)); + + WatchUi.pushView(menu, new ActivityControlMenuDelegate(self), WatchUi.SLIDE_UP); + } + + function showPausedControlMenu() as Void { + var menu = new WatchUi.Menu2({ :title => "Activity Paused" }); + menu.addItem(new WatchUi.MenuItem("Resume", "Continue", :resume_activity, null)); + menu.addItem(new WatchUi.MenuItem("Stop", "Stop activity", :stop_activity, null)); + + WatchUi.pushView(menu, new ActivityControlMenuDelegate(self), WatchUi.SLIDE_UP); + } + + function showSaveDiscardMenu() as Void { + var menu = new WatchUi.Menu2({ :title => "Save Activity?" }); + menu.addItem(new WatchUi.MenuItem("Save", "Save session", :save_session, null)); + menu.addItem(new WatchUi.MenuItem("Discard", "Discard session", :discard_session, null)); + + WatchUi.pushView(menu, new SaveDiscardMenuDelegate(self), WatchUi.SLIDE_UP); + } + function pushSettingsView() as Void { + var settingsMenu = new WatchUi.Menu2({ :title => "Settings" }); settingsMenu.addItem(new WatchUi.MenuItem("Profile", null, :set_profile, null)); settingsMenu.addItem(new WatchUi.MenuItem("Customization", null, :cust_options, null)); settingsMenu.addItem(new WatchUi.MenuItem("Feedback", null, :feedback_options, null)); @@ -96,8 +141,131 @@ class SimpleViewDelegate extends WatchUi.BehaviorDelegate { WatchUi.pushView(settingsMenu, new SettingsMenuDelegate(), WatchUi.SLIDE_UP); } + function setMenuActive(active as Boolean) as Void { + _menuActive = active; + System.println("[DEBUG] Menu active state set to: " + active); + } + function onBack() as Boolean { - // Back button disabled - no input - return true; + var app = getApp(); + + if (app.isRecording() || app.isPaused() || app.isStopped()) { + System.println("[UI] Session active - use Stop to exit"); + return true; + } + + return false; + } +} + +class ActivityControlMenuDelegate extends WatchUi.Menu2InputDelegate { + + private var _parentDelegate; + + function initialize(parentDelegate) { + Menu2InputDelegate.initialize(); + _parentDelegate = parentDelegate; + } + + function onSelect(item as WatchUi.MenuItem) as Void { + var id = item.getId(); + var app = getApp(); + + System.println("[DEBUG] Menu item selected: " + id); + + if (id == :pause_activity) { + app.pauseRecording(); + System.println("[UI] Activity paused"); + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.requestUpdate(); + + } else if (id == :resume_activity) { + if (app.isPaused()) { + app.resumeRecording(); + System.println("[UI] Activity resumed"); + } + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.requestUpdate(); + + } else if (id == :stop_activity) { + app.stopRecording(); + System.println("[UI] Activity stopped"); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + + var menu = new WatchUi.Menu2({ :title => "Save Activity?" }); + menu.addItem(new WatchUi.MenuItem("Save", "Save session", :save_session, null)); + menu.addItem(new WatchUi.MenuItem("Discard", "Discard session", :discard_session, null)); + WatchUi.pushView(menu, new SaveDiscardMenuDelegate(_parentDelegate), WatchUi.SLIDE_UP); + } + } + + function onBack() as Void { + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } +} + +class SaveDiscardMenuDelegate extends WatchUi.Menu2InputDelegate { + + private var _parentDelegate; + + function initialize(parentDelegate) { + Menu2InputDelegate.initialize(); + _parentDelegate = parentDelegate; + } + + function onSelect(item as WatchUi.MenuItem) as Void { + var id = item.getId(); + var app = getApp(); + + System.println("[DEBUG] Save/Discard selected: " + id); + + if (id == :save_session) { + app.saveSession(); + System.println("[UI] Activity saved"); + _parentDelegate.setMenuActive(false); + + var confirmationMenu = new WatchUi.Menu2({ :title => "Activity Saved!" }); + confirmationMenu.addItem(new WatchUi.MenuItem("Done", null, :done, null)); + WatchUi.pushView(confirmationMenu, new ConfirmationDelegate(_parentDelegate), WatchUi.SLIDE_IMMEDIATE); + + } else if (id == :discard_session) { + app.discardSession(); + System.println("[UI] Activity discarded"); + _parentDelegate.setMenuActive(false); + + var confirmationMenu = new WatchUi.Menu2({ :title => "Activity Discarded" }); + confirmationMenu.addItem(new WatchUi.MenuItem("Done", null, :done, null)); + WatchUi.pushView(confirmationMenu, new ConfirmationDelegate(_parentDelegate), WatchUi.SLIDE_IMMEDIATE); + } + + WatchUi.requestUpdate(); + } + + function onBack() as Void { + } +} + +class ConfirmationDelegate extends WatchUi.Menu2InputDelegate { + + private var _parentDelegate; + + function initialize(parentDelegate) { + Menu2InputDelegate.initialize(); + _parentDelegate = parentDelegate; + } + + function onSelect(item as WatchUi.MenuItem) as Void { + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + } + + function onBack() as Void { + _parentDelegate.setMenuActive(false); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); } } diff --git a/source/GarminApp.mc b/source/GarminApp.mc index 9e39074..ef16858 100644 --- a/source/GarminApp.mc +++ b/source/GarminApp.mc @@ -3,12 +3,12 @@ import Toybox.Lang; import Toybox.WatchUi; import Toybox.Timer; import Toybox.Activity; +import Toybox.ActivityRecording; import Toybox.System; import Toybox.Application.Storage; class GarminApp extends Application.AppBase { const MAX_BARS = 280; - //const MAX_BARS_DISPLAY = 0; const BASELINE_AVG_CADENCE = 160; const MAX_CADENCE = 190; const MIN_CQ_SAMPLES = 30; @@ -24,9 +24,18 @@ class GarminApp extends Application.AppBase { const PROP_MAX_CADENCE = "maxCadence"; var globalTimer; - var isRecording as Boolean = false; + var activitySession; // Garmin activity recording session - enum { //each chart corresponds to a difference bar duration average (in seconds) + enum SessionState { + IDLE, + RECORDING, + PAUSED, + STOPPED + } + + private var _sessionState as SessionState = IDLE; + + enum { FifteenminChart = 3, ThirtyminChart = 6, OneHourChart = 13, @@ -52,9 +61,8 @@ class GarminApp extends Application.AppBase { Other } - //default value (can change in settings) - private var _userHeight = 170;//>>cm - private var _userSpeed = 10;//>>km/h + private var _userHeight = 170; + private var _userSpeed = 10; private var _experienceLvl = Beginner; private var _userGender = Male; private var _chartDuration = ThirtyminChart as Number; @@ -62,11 +70,11 @@ class GarminApp extends Application.AppBase { private var _idealMinCadence = 120; private var _idealMaxCadence = 150; - private var _cadenceHistory as Array = new [MAX_BARS]; // Store session's cadence + private var _cadenceHistory as Array = new [MAX_BARS]; private var _cadenceIndex = 0; private var _cadenceCount = 0; - private var _cadenceBarAvg as Array = new [_chartDuration]; // Store data points for display + private var _cadenceBarAvg as Array = new [_chartDuration]; private var _cadenceAvgIndex = 0; private var _cadenceAvgCount = 0; @@ -75,6 +83,10 @@ class GarminApp extends Application.AppBase { private var _finalCQConfidence = null; private var _finalCQTrend = null; private var _cqHistory as Array = []; + + private var _sessionStartTime = null; + private var _sessionPausedTime = 0; + private var _lastPauseTime = null; // Activity metrics captured when monitoring stops private var _sessionDuration = null; // milliseconds @@ -85,12 +97,11 @@ class GarminApp extends Application.AppBase { function initialize() { AppBase.initialize(); System.println("[INFO] App initialized"); + activitySession = null; } function onStart(state as Dictionary?) as Void { System.println("[INFO] App starting"); - - // Log memory on startup Logger.logMemoryStats("Startup"); // Load saved settings from persistent storage @@ -98,53 +109,126 @@ class GarminApp extends Application.AppBase { globalTimer = new Timer.Timer(); globalTimer.start(method(:updateCadenceBarAvg),1000,true); - - // Auto-calculate ideal cadence if user has configured profile - idealCadenceCalculator(); } function onStop(state as Dictionary?) as Void { System.println("[INFO] App stopping"); - + // Stop any active session + if (activitySession != null && activitySession.isRecording()) { + activitySession.stop(); + activitySession = null; + } if(globalTimer != null){ globalTimer.stop(); globalTimer = null; } - // Log memory on shutdown Logger.logMemoryStats("Shutdown"); } function startRecording() as Void { + if (_sessionState == RECORDING) { + System.println("[INFO] Already recording"); + return; + } - if (isRecording) {return;} + System.println("[INFO] Starting activity session"); - System.println("[INFO] Starting cadence monitoring"); + // Create and start Garmin activity session + activitySession = ActivityRecording.createSession({ + :name => "Running", + :sport => ActivityRecording.SPORT_RUNNING, + :subSport => ActivityRecording.SUB_SPORT_GENERIC + }); + + activitySession.start(); + System.println("[INFO] Garmin activity session started"); + // Reset cadence monitoring data _finalCQ = null; _finalCQConfidence = null; _finalCQTrend = null; _cqHistory = []; _cadenceCount = 0; + _cadenceIndex = 0; + _cadenceAvgCount = 0; + _cadenceAvgIndex = 0; _missingCadenceCount = 0; + _sessionStartTime = System.getTimer(); + _sessionPausedTime = 0; + _lastPauseTime = null; - // Reset activity metrics - _sessionDuration = null; - _sessionDistance = null; - _avgHeartRate = null; - _peakHeartRate = null; + for (var i = 0; i < MAX_BARS; i++) { + _cadenceHistory[i] = null; + } + for (var i = 0; i < _chartDuration; i++) { + _cadenceBarAvg[i] = null; + } - isRecording = true; + _sessionState = RECORDING; + System.println("[INFO] Starting cadence monitoring"); } + function pauseRecording() as Void { + if (_sessionState != RECORDING) { + System.println("[INFO] Cannot pause - not recording"); + return; + } + + System.println("[INFO] Pausing activity session"); + + // Pause Garmin activity session + if (activitySession != null && activitySession.isRecording()) { + activitySession.stop(); + System.println("[INFO] Garmin activity session paused"); + } + + _lastPauseTime = System.getTimer(); + _sessionState = PAUSED; + } + + function resumeRecording() as Void { + if (_sessionState != PAUSED) { + System.println("[INFO] Cannot resume - not paused"); + return; + } + + System.println("[INFO] Resuming activity session"); + + // Resume Garmin activity session + if (activitySession != null && !activitySession.isRecording()) { + activitySession.start(); + System.println("[INFO] Garmin activity session resumed"); + } + + if (_lastPauseTime != null) { + _sessionPausedTime += System.getTimer() - _lastPauseTime; + _lastPauseTime = null; + } + + _sessionState = RECORDING; + } function stopRecording() as Void { + if (_sessionState == IDLE || _sessionState == STOPPED) { + System.println("[INFO] No active session to stop"); + return; + } + + System.println("[INFO] Stopping activity session"); - if (!isRecording) {return;} + // Stop Garmin activity session (but don't save or discard yet) + if (activitySession != null && activitySession.isRecording()) { + activitySession.stop(); + System.println("[INFO] Garmin activity session stopped"); + } - System.println("[INFO] Stopping cadence monitoring"); + if (_sessionState == PAUSED && _lastPauseTime != null) { + _sessionPausedTime += System.getTimer() - _lastPauseTime; + _lastPauseTime = null; + } // Capture activity metrics before stopping captureActivityMetrics(); @@ -166,7 +250,79 @@ class GarminApp extends Application.AppBase { writeDiagnosticLog(); } - isRecording = false; + _sessionState = STOPPED; + } + + function saveSession() as Void { + if (_sessionState != STOPPED) { + System.println("[INFO] Cannot save - session not stopped"); + return; + } + + System.println("[INFO] Saving activity session"); + + // Save Garmin activity session + if (activitySession != null) { + activitySession.save(); + System.println("[INFO] Garmin activity session saved to FIT file"); + activitySession = null; + } + + var totalTime = 0; + if (_sessionStartTime != null) { + totalTime = System.getTimer() - _sessionStartTime - _sessionPausedTime; + } + + System.println("===== SESSION SAVED ====="); + System.println("Duration: " + (totalTime / 1000).format("%d") + " seconds"); + System.println("Cadence samples: " + _cadenceCount.toString()); + System.println("Final CQ: " + (_finalCQ != null ? _finalCQ.format("%d") + "%" : "N/A")); + System.println("========================"); + + resetSession(); + } + + function discardSession() as Void { + if (_sessionState != STOPPED) { + System.println("[INFO] Cannot discard - session not stopped"); + return; + } + + System.println("[INFO] Discarding activity session"); + + // Discard Garmin activity session + if (activitySession != null) { + activitySession.discard(); + System.println("[INFO] Garmin activity session discarded"); + activitySession = null; + } + + resetSession(); + } + + function resetSession() as Void { + System.println("[INFO] Resetting session"); + + _sessionState = IDLE; + _finalCQ = null; + _finalCQConfidence = null; + _finalCQTrend = null; + _cqHistory = []; + _cadenceCount = 0; + _cadenceIndex = 0; + _cadenceAvgCount = 0; + _cadenceAvgIndex = 0; + _missingCadenceCount = 0; + _sessionStartTime = null; + _sessionPausedTime = 0; + _lastPauseTime = null; + + for (var i = 0; i < MAX_BARS; i++) { + _cadenceHistory[i] = null; + } + for (var i = 0; i < _chartDuration; i++) { + _cadenceBarAvg[i] = null; + } } function captureActivityMetrics() as Void { @@ -193,20 +349,21 @@ class GarminApp extends Application.AppBase { } function updateCadenceBarAvg() as Void { - //if (!isRecording) { return;} // ignore samples when not actively monitoring + // CRITICAL: Only collect data when RECORDING + if (_sessionState != RECORDING) { + return; + } - var info = Activity.getActivityInfo(); + var info = Activity.getActivityInfo(); if (info != null && info.currentCadence != null) { var newCadence = info.currentCadence; _cadenceBarAvg[_cadenceAvgIndex] = newCadence.toFloat(); - // Add to circular buffer _cadenceAvgIndex = (_cadenceAvgIndex + 1) % _chartDuration; if (_cadenceAvgCount < _chartDuration) { _cadenceAvgCount++; } - else //calculate avg - { + else { var barAvg = 0.0; for(var i = 0; i < _chartDuration; i++){ barAvg += _cadenceBarAvg[i]; @@ -215,12 +372,10 @@ class GarminApp extends Application.AppBase { _cadenceAvgCount = 0; } } - } function updateCadenceHistory(newCadence as Float) as Void { _cadenceHistory[_cadenceIndex] = newCadence; - // Add to circular buffer _cadenceIndex = (_cadenceIndex + 1) % MAX_BARS; if (_cadenceCount < MAX_BARS) { _cadenceCount++; } @@ -228,11 +383,9 @@ class GarminApp extends Application.AppBase { System.println("[CADENCE] " + newCadence); } else { - // Track missing cadence samples (sensor dropouts) _missingCadenceCount++; } - // ----- Cadence Quality computation ----- var cq = computeCadenceQualityScore(); if (cq < 0) { @@ -246,28 +399,21 @@ class GarminApp extends Application.AppBase { System.println("[CADENCE QUALITY] CQ = " + cq.format("%d") + "%"); } - // Record CQ history for trend analysis _cqHistory.add(cq); - // Keep sliding window small and recent if (_cqHistory.size() > 10) { _cqHistory.remove(0); } } - // ----- Memory logging (approx once per minute) ----- if (_cadenceIndex % 60 == 0 && _cadenceIndex > 0) { Logger.logMemoryStats("Runtime"); } - } - // Cadence Quality function computeTimeInZoneScore() as Number { - - // Not enough data yet if (_cadenceCount < MIN_CQ_SAMPLES) { - return -1; // sentinel value meaning "not ready" + return -1; } var minZone = _idealMinCadence; @@ -296,15 +442,12 @@ class GarminApp extends Application.AppBase { return (ratio * 100).toNumber(); } - - function idealCadenceCalculator() as Void { var referenceCadence = 0; var finalCadence = 0; var userLegLength = _userHeight * 0.53; - var userSpeedms = _userSpeed / 3.6;//km/h --> m/s + var userSpeedms = _userSpeed / 3.6; - //reference cadence switch (_userGender) { case Male: referenceCadence = (-1.268 * userLegLength) + (3.471 * userSpeedms) + 261.378; @@ -317,14 +460,10 @@ class GarminApp extends Application.AppBase { break; } - //experience adjustment referenceCadence = referenceCadence * _experienceLvl; - - //apply threshold referenceCadence = Math.round(referenceCadence); finalCadence = max(BASELINE_AVG_CADENCE,min(referenceCadence,MAX_CADENCE)).toNumber(); - //set new min max ideal cadence _idealMaxCadence = finalCadence + 5; _idealMinCadence = finalCadence - 5; @@ -335,10 +474,8 @@ class GarminApp extends Application.AppBase { } function computeSmoothnessScore() as Number { - - // Not enough data yet if (_cadenceCount < MIN_CQ_SAMPLES) { - return -1; // not ready + return -1; } var totalDiff = 0.0; @@ -359,17 +496,8 @@ class GarminApp extends Application.AppBase { } var avgDiff = totalDiff / diffCount; - - /* - Interpret avgDiff: - - ~0–1 β†’ very smooth - - ~2–3 β†’ normal - - >5 β†’ erratic - */ - var rawScore = 100 - (avgDiff * 10); - // Clamp to 0–100 if (rawScore < 0) { rawScore = 0; } if (rawScore > 100) { rawScore = 100; } @@ -377,27 +505,18 @@ class GarminApp extends Application.AppBase { } function computeCadenceQualityScore() as Number { - var timeInZone = computeTimeInZoneScore(); var smoothness = computeSmoothnessScore(); - // Not ready yet if (timeInZone < 0 || smoothness < 0) { return -1; } - // Weighted combination - var cq = - (timeInZone * 0.7) + - (smoothness * 0.3); - + var cq = (timeInZone * 0.7) + (smoothness * 0.3); return cq.toNumber(); } - function computeCQConfidence() as String { - - // Not enough data β†’ low confidence if (_cadenceCount < MIN_CQ_SAMPLES) { return "Low"; } @@ -415,14 +534,12 @@ class GarminApp extends Application.AppBase { } function computeCQTrend() as String { - if (_cqHistory.size() < 5) { return "Stable"; } var first = _cqHistory[0]; var last = _cqHistory[_cqHistory.size() - 1]; - var delta = last - first; if (delta < -5) { @@ -435,22 +552,17 @@ class GarminApp extends Application.AppBase { } function writeDiagnosticLog() as Void { - if (!DEBUG_MODE) { return; } System.println("===== DIAGNOSTIC RUN SUMMARY ====="); - System.println("Final CQ: " + (_finalCQ != null ? _finalCQ.format("%d") + "%" : "N/A")); - System.println("CQ Confidence: " + (_finalCQConfidence != null ? _finalCQConfidence : "N/A")); - System.println("CQ Trend: " + (_finalCQTrend != null ? _finalCQTrend : "N/A")); - System.println("Cadence samples collected: " + _cadenceCount.toString()); System.println("Missing cadence samples: " + _missingCadenceCount.toString()); @@ -458,22 +570,37 @@ class GarminApp extends Application.AppBase { if (totalSamples > 0) { var validRatio = (_cadenceCount.toFloat() / totalSamples.toFloat()) * 100; - - System.println("Valid data ratio: " + - validRatio.format("%d") + "%"); + System.println("Valid data ratio: " + validRatio.format("%d") + "%"); } System.println("Ideal cadence range: " + _idealMinCadence.toString() + "-" + _idealMaxCadence.toString()); - System.println("===== END DIAGNOSTIC SUMMARY ====="); } + function getSessionState() as SessionState { + return _sessionState; + } + + function isRecording() as Boolean { + return _sessionState == RECORDING; + } + + function isPaused() as Boolean { + return _sessionState == PAUSED; + } + + function isStopped() as Boolean { + return _sessionState == STOPPED; + } + + function isIdle() as Boolean { + return _sessionState == IDLE; + } - //set and get functions function isActivityRecording() as Boolean { - return isRecording; + return _sessionState == RECORDING || _sessionState == PAUSED; } function getMinCadence() as Number { @@ -508,7 +635,6 @@ class GarminApp extends Application.AppBase { function setChartDuration(value as Number) as Void { _chartDuration = value; - saveSettings(); System.println(CHART_ENUM_NAMES[_chartDuration] + " selected."); } @@ -569,93 +695,32 @@ class GarminApp extends Application.AppBase { } function getFinalCadenceQuality() { - return _finalCQ; - + return _finalCQ; } function getFinalCQConfidence() { - return _finalCQConfidence; + return _finalCQConfidence; } function getFinalCQTrend() { - return _finalCQTrend; + return _finalCQTrend; } - // ----------------------- - // Persistent Storage Methods - // ----------------------- - - function loadSettings() as Void { - System.println("[SETTINGS] Loading saved preferences..."); - - // Load user height - var height = Storage.getValue(PROP_USER_HEIGHT); - if (height != null) { - _userHeight = height as Number; - System.println("[SETTINGS] Loaded height: " + _userHeight.toString() + " cm"); + function getSessionDuration() as Number { + if (_sessionStartTime == null) { + return 0; } - // Load user speed - var speed = Storage.getValue(PROP_USER_SPEED); - if (speed != null) { - _userSpeed = speed as Float; - System.println("[SETTINGS] Loaded speed: " + _userSpeed.toString() + " km/h"); - } - - // Load user gender - var gender = Storage.getValue(PROP_USER_GENDER); - if (gender != null) { - _userGender = gender as Number; - System.println("[SETTINGS] Loaded gender: " + _userGender.toString()); - } - - // Load experience level - var experience = Storage.getValue(PROP_EXPERIENCE_LVL); - if (experience != null) { - _experienceLvl = experience as Float; - System.println("[SETTINGS] Loaded experience level: " + _experienceLvl.toString()); - } - - // Load chart duration - var chartDur = Storage.getValue(PROP_CHART_DURATION); - if (chartDur != null) { - _chartDuration = chartDur as Number; - System.println("[SETTINGS] Loaded chart duration: " + CHART_ENUM_NAMES[_chartDuration]); - } - - // Load cadence zones (if manually set) - var minCad = Storage.getValue(PROP_MIN_CADENCE); - if (minCad != null) { - _idealMinCadence = minCad as Number; - System.println("[SETTINGS] Loaded min cadence: " + _idealMinCadence.toString()); - } + var currentTime = System.getTimer(); + var totalTime = currentTime - _sessionStartTime - _sessionPausedTime; - var maxCad = Storage.getValue(PROP_MAX_CADENCE); - if (maxCad != null) { - _idealMaxCadence = maxCad as Number; - System.println("[SETTINGS] Loaded max cadence: " + _idealMaxCadence.toString()); + if (_sessionState == PAUSED && _lastPauseTime != null) { + totalTime -= (currentTime - _lastPauseTime); } - System.println("[SETTINGS] Settings loaded successfully"); - } - - function saveSettings() as Void { - System.println("[SETTINGS] Saving preferences..."); - - // Save all user settings using Storage API - Storage.setValue(PROP_USER_HEIGHT, _userHeight); - Storage.setValue(PROP_USER_SPEED, _userSpeed); - Storage.setValue(PROP_USER_GENDER, _userGender); - Storage.setValue(PROP_EXPERIENCE_LVL, _experienceLvl); - Storage.setValue(PROP_CHART_DURATION, _chartDuration); - Storage.setValue(PROP_MIN_CADENCE, _idealMinCadence); - Storage.setValue(PROP_MAX_CADENCE, _idealMaxCadence); - - System.println("[SETTINGS] Settings saved successfully"); + return totalTime / 1000; } - - // Return the initial view of your application here function getInitialView() as [Views] or [Views, InputDelegates] { return [ new SimpleView(), new SimpleViewDelegate() ]; } @@ -746,5 +811,3 @@ class GarminApp extends Application.AppBase { function getApp() as GarminApp { return Application.getApp() as GarminApp; } - - diff --git a/source/Views/AdvancedView.mc b/source/Views/AdvancedView.mc index 52c9ea7..f942ab4 100644 --- a/source/Views/AdvancedView.mc +++ b/source/Views/AdvancedView.mc @@ -4,12 +4,22 @@ import Toybox.Activity; import Toybox.Lang; import Toybox.Timer; import Toybox.System; +import Toybox.Attention; class AdvancedView extends WatchUi.View { const MAX_BARS = 280; const MAX_CADENCE_DISPLAY = 200; private var _simulationTimer; + + // Vibration alert tracking (no extra timers needed!) + private var _lastZoneState = 0; // -1 = below, 0 = inside, 1 = above + private var _alertStartTime = null; + private var _alertDuration = 180000; // 3 minutes in milliseconds + private var _alertInterval = 30000; // 30 seconds in milliseconds + private var _lastAlertTime = 0; + private var _pendingSecondVibe = false; + private var _secondVibeTime = 0; function initialize() { View.initialize(); @@ -25,10 +35,19 @@ class AdvancedView extends WatchUi.View { _simulationTimer.stop(); _simulationTimer = null; } + // Reset alert state + _alertStartTime = null; + _lastAlertTime = 0; } function onUpdate(dc as Dc) as Void { - View.onUpdate(dc); + // Check cadence zone for vibration alerts + checkCadenceZone(); + + // Check for pending second vibration + checkPendingVibration(); + + View.onUpdate(dc); // Draw all the elements drawElements(dc); } @@ -36,8 +55,112 @@ class AdvancedView extends WatchUi.View { function refreshScreen() as Void { WatchUi.requestUpdate(); } + + function checkPendingVibration() as Void { + if (_pendingSecondVibe) { + var currentTime = System.getTimer(); + if (currentTime >= _secondVibeTime) { + // Trigger second vibration + if (Attention has :vibrate) { + var vibeData = [new Attention.VibeProfile(50, 200)]; + Attention.vibrate(vibeData); + } + _pendingSecondVibe = false; + } + } + } + + function triggerSingleVibration() as Void { + if (Attention has :vibrate) { + var vibeData = [new Attention.VibeProfile(50, 200)]; + Attention.vibrate(vibeData); + } + } + + function triggerDoubleVibration() as Void { + if (Attention has :vibrate) { + // First vibration + var vibeData = [new Attention.VibeProfile(50, 200)]; + Attention.vibrate(vibeData); + + // Schedule second vibration after 240ms + _pendingSecondVibe = true; + _secondVibeTime = System.getTimer() + 240; + } + } + + function checkAndTriggerAlerts() as Void { + // Only check if we're in an alert period + if (_alertStartTime == null) { + return; + } + + var currentTime = System.getTimer(); + var elapsed = currentTime - _alertStartTime; + + // Stop alerting after 3 minutes + if (elapsed >= _alertDuration) { + _alertStartTime = null; + _lastAlertTime = 0; + return; + } + + // Check if it's time for the next alert (every 30 seconds) + var timeSinceLastAlert = currentTime - _lastAlertTime; + if (timeSinceLastAlert >= _alertInterval) { + _lastAlertTime = currentTime; + + // Trigger the appropriate vibration + if (_lastZoneState == -1) { + triggerSingleVibration(); + } else if (_lastZoneState == 1) { + triggerDoubleVibration(); + } + } + } + + function checkCadenceZone() as Void { + var info = Activity.getActivityInfo(); + var app = getApp(); + var minZone = app.getMinCadence(); + var maxZone = app.getMaxCadence(); + + // Determine zone state + var newZoneState = 0; + if (info != null && info.currentCadence != null) { + var c = info.currentCadence; + if (c < minZone) { + newZoneState = -1; + } else if (c > maxZone) { + newZoneState = 1; + } else { + newZoneState = 0; + } + } - + // Trigger alerts on zone crossing + if (newZoneState != _lastZoneState) { + if (newZoneState == -1) { + // Below minimum - start alert cycle + _alertStartTime = System.getTimer(); + _lastAlertTime = System.getTimer(); + triggerSingleVibration(); + } else if (newZoneState == 1) { + // Above maximum - start alert cycle + _alertStartTime = System.getTimer(); + _lastAlertTime = System.getTimer(); + triggerDoubleVibration(); + } else { + // Back in zone - stop alerts + _alertStartTime = null; + _lastAlertTime = 0; + } + _lastZoneState = newZoneState; + } else { + // Still out of zone - check if we need to alert again + checkAndTriggerAlerts(); + } + } function drawElements(dc as Dc) as Void { var width = dc.getWidth(); @@ -100,10 +223,13 @@ class AdvancedView extends WatchUi.View { drawChart(dc); - var string = app.getChartDuration(); + // Display cadence zone range instead of time duration + var minZone = app.getMinCadence(); + var maxZone = app.getMaxCadence(); + var zoneText = "Zone: " + minZone.toString() + "-" + maxZone.toString() + " spm"; dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT); - dc.drawText(width / 2, chartDurationY, Graphics.FONT_XTINY, "Last " + string, Graphics.TEXT_JUSTIFY_CENTER); + dc.drawText(width / 2, chartDurationY, Graphics.FONT_XTINY, zoneText, Graphics.TEXT_JUSTIFY_CENTER); } @@ -116,6 +242,7 @@ class AdvancedView extends WatchUi.View { Each update the watchUI redraws the chart with the latest data. } **/ + function drawChart(dc as Dc) as Void { var width = dc.getWidth(); var height = dc.getHeight(); @@ -123,7 +250,6 @@ class AdvancedView extends WatchUi.View { //margins value var margin = width * 0.1; var marginLeftRightMultiplier = 1.38; - //var marginTopMultiplier = 0.5; var marginBottomMultiplier = 1.6; //chart position @@ -150,8 +276,7 @@ class AdvancedView extends WatchUi.View { var line2x2 = chartRight + lineLength; var lineY = chartTop + quarterChartHeight; - - // Draw white border around chart (RGB: 255,255,255 = 0xFFFFFF) + // Draw white border around chart dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT); dc.drawRectangle(chartLeft, chartTop, chartWidth, chartHeight); for(var i = 0; i < nLine; i++){ @@ -165,69 +290,53 @@ class AdvancedView extends WatchUi.View { var idealMinCadence = app.getMinCadence(); var idealMaxCadence = app.getMaxCadence(); var cadenceHistory = app.getCadenceHistory(); - - //hardcoded natural running cadence test array - /* - var cadenceHistory = [ - // Long warm-up/easy pace phase (bars 0-220): staying relatively low around 62-100 with noise - 62, 65, 63, 68, 66, 70, 67, 72, 69, 74, - 71, 76, 73, 78, 75, 81, 77, 83, 79, 85, - 82, 87, 84, 89, 86, 91, 88, 94, 90, 96, - 93, 98, 95, 100, 97, 102, 99, 104, 101, 103, - 100, 98, 95, 97, 94, 96, 92, 94, 90, 92, - 88, 90, 86, 88, 84, 86, 82, 85, 80, 83, - 81, 84, 82, 86, 83, 88, 85, 90, 87, 92, - 89, 94, 91, 96, 93, 98, 95, 100, 97, 102, - 99, 104, 101, 106, 103, 105, 102, 100, 98, 96, - 94, 92, 90, 88, 86, 84, 82, 85, 83, 87, - 85, 89, 87, 91, 89, 93, 91, 95, 93, 97, - 95, 99, 97, 101, 99, 100, 98, 96, 94, 92, - 90, 88, 86, 84, 82, 80, 78, 81, 79, 83, - 81, 85, 83, 87, 85, 89, 87, 91, 89, 93, - 91, 95, 93, 97, 95, 99, 97, 101, 99, 103, - 101, 105, 103, 107, 105, 106, 104, 102, 100, 98, - 96, 94, 92, 90, 88, 86, 84, 87, 85, 89, - 87, 91, 89, 93, 91, 88, 86, 84, 82, 85, - 83, 87, 85, 89, 87, 91, 89, 93, 91, 95, - 93, 97, 95, 99, 97, 101, 99, 100, 98, 96, - 94, 92, 90, 88, 91, 89, 93, 91, 95, 93, - 97, 95, 99, 97, 101, 99, 103, 101, 105, 103, - 107, - - // Sprint phase (bars 221-250): rapid increase to peak ~178 with noise - 109, 113, 110, 118, 115, 123, 120, 128, 125, 133, - 130, 138, 135, 143, 140, 148, 138, 135, 143, 140, 148, 163, 160, 168, 165, 173, 170, 178, 175, 176, - 115, 108, 110, 103, 105, 98, 93, 91, 95, 93, 154, 151, - - // Cool-down phase (bars 251-280): decrease back to ~62 with noise - 148, 143, 145, 138, 140, 133, 135, 128, 130, 123, - 125, 118, 120, 113, 115, 108, 110, 103, 105, 98, - 100, 118, 120, 113, 115, 108, 110, 78, 80, 73, - 75, 68, 70, 65, 67, 62, 64, 60, 62, 63 - ];*/ var cadenceIndex = app.getCadenceIndex(); var cadenceCount = app.getCadenceCount(); - //check array ?null + if(cadenceCount == 0) {return;} var numBars = cadenceCount; var barWidth = (barZoneWidth / MAX_BARS).toNumber(); - var startIndex = (cadenceIndex - numBars + MAX_BARS) % MAX_BARS; - - // Draw bars + + var colorThreshold = 20; + + // FIXED SCALE - bars have fixed height based on absolute cadence + // Colors change dynamically based on your zone for (var i = 0; i < numBars; i++) { - var index = (startIndex + i) % MAX_BARS; // Start from oldest data + var index = (startIndex + i) % MAX_BARS; var cadence = cadenceHistory[index]; if(cadence == null) {cadence = 0;} - - //calculate bar height and position + + // Fixed bar height - same cadence always same height var barHeight = ((cadence / MAX_CADENCE_DISPLAY) * chartHeight).toNumber(); var x = barZoneLeft + i * barWidth; var y = barZoneBottom - barHeight; - - correctColor(cadence, idealMinCadence, idealMaxCadence, dc); + + // Dynamic color based on YOUR current zone + //FML + if (cadence < idealMinCadence - colorThreshold) { + // Way below zone - Grey + dc.setColor(0x969696, Graphics.COLOR_TRANSPARENT); + } + else if (cadence >= idealMinCadence - colorThreshold && cadence < idealMinCadence) { + // Below zone - Blue + dc.setColor(0x0cc0df, Graphics.COLOR_TRANSPARENT); + } + else if (cadence >= idealMinCadence && cadence <= idealMaxCadence) { + // In zone - Green (YOUR ZONE!) + dc.setColor(0x00bf63, Graphics.COLOR_TRANSPARENT); + } + else if (cadence > idealMaxCadence && cadence <= idealMaxCadence + colorThreshold) { + // Above zone - Orange + dc.setColor(0xff751f, Graphics.COLOR_TRANSPARENT); + } + else if (cadence > idealMaxCadence + colorThreshold) { + // Way above zone - Red + dc.setColor(0xFF0000, Graphics.COLOR_TRANSPARENT); + } + dc.fillRectangle(x, y, barWidth, barHeight); } } diff --git a/source/Views/SimpleView.mc b/source/Views/SimpleView.mc index f68fce4..a41ea8b 100644 --- a/source/Views/SimpleView.mc +++ b/source/Views/SimpleView.mc @@ -4,6 +4,7 @@ import Toybox.Activity; import Toybox.Lang; import Toybox.Timer; import Toybox.System; +import Toybox.Attention; class SimpleView extends WatchUi.View { @@ -14,33 +15,21 @@ class SimpleView extends WatchUi.View { private var _timeDisplay; private var _cadenceZoneDisplay; private var _lastZoneState = 0; // -1 = below, 0 = inside, 1 = above - private var _vibeTimer = new Timer.Timer(); private var _cqDisplay; - - function _secondVibe() as Void { - // Haptics not available on this target SDK/device in this workspace. - // Replace the println below with the device vibration call when supported, - // e.g. `Haptics.vibrate(120)` or `System.vibrate(120)` on SDKs that provide it. - System.println("[vibe] second pulse"); - } + private var _hardcoreDisplay; + + // Vibration alert tracking (no extra timers needed!) + private var _alertStartTime = null; + private var _alertDuration = 180000; // 3 minutes in milliseconds + private var _alertInterval = 30000; // 30 seconds in milliseconds + private var _lastAlertTime = 0; + private var _pendingSecondVibe = false; + private var _secondVibeTime = 0; function initialize() { View.initialize(); } - // Load your resources here - function onLayout(dc as Dc) as Void { - setLayout(Rez.Layouts.MainLayout(dc)); - _cadenceDisplay = findDrawableById("cadence_text"); - _cadenceZoneDisplay = findDrawableById("cadence_zone"); - _heartrateDisplay = findDrawableById("heartrate_text"); - _distanceDisplay = findDrawableById("distance_text"); - _timeDisplay = findDrawableById("time_text"); - _cqDisplay = findDrawableById("cq_text"); - - - } - // 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. @@ -54,6 +43,9 @@ class SimpleView extends WatchUi.View { //update the display for current cadence displayCadence(); + // Check for pending second vibration + checkPendingVibration(); + // Draw recording indicator drawRecordingIndicator(dc); @@ -69,11 +61,77 @@ class SimpleView extends WatchUi.View { _refreshTimer.stop(); _refreshTimer = null; } + // Reset alert state + _alertStartTime = null; + _lastAlertTime = 0; } function refreshScreen() as Void{ WatchUi.requestUpdate(); } + + function checkPendingVibration() as Void { + if (_pendingSecondVibe) { + var currentTime = System.getTimer(); + if (currentTime >= _secondVibeTime) { + // Trigger second vibration + if (Attention has :vibrate) { + var vibeData = [new Attention.VibeProfile(50, 200)]; + Attention.vibrate(vibeData); + } + _pendingSecondVibe = false; + } + } + } + + function triggerSingleVibration() as Void { + if (Attention has :vibrate) { + var vibeData = [new Attention.VibeProfile(50, 200)]; + Attention.vibrate(vibeData); + } + } + + function triggerDoubleVibration() as Void { + if (Attention has :vibrate) { + // First vibration + var vibeData = [new Attention.VibeProfile(50, 200)]; + Attention.vibrate(vibeData); + + // Schedule second vibration after 240ms + _pendingSecondVibe = true; + _secondVibeTime = System.getTimer() + 240; + } + } + + function checkAndTriggerAlerts() as Void { + // Only check if we're in an alert period + if (_alertStartTime == null) { + return; + } + + var currentTime = System.getTimer(); + var elapsed = currentTime - _alertStartTime; + + // Stop alerting after 3 minutes + if (elapsed >= _alertDuration) { + _alertStartTime = null; + _lastAlertTime = 0; + return; + } + + // Check if it's time for the next alert (every 30 seconds) + var timeSinceLastAlert = currentTime - _lastAlertTime; + if (timeSinceLastAlert >= _alertInterval) { + _lastAlertTime = currentTime; + + // Trigger the appropriate vibration + if (_lastZoneState == -1) { + triggerSingleVibration(); + } else if (_lastZoneState == 1) { + triggerDoubleVibration(); + } + } + } function drawRecordingIndicator(dc as Dc) as Void { var app = getApp(); @@ -125,7 +183,7 @@ class SimpleView extends WatchUi.View { _cadenceZoneDisplay.setText(zoneText); } - // Trigger haptic on zone crossing: single when falling below min, double when going above max + // Trigger haptic on zone crossing with timed alerts var newZoneState = 0; if (info != null && info.currentCadence != null) { var c = info.currentCadence; @@ -140,16 +198,24 @@ class SimpleView extends WatchUi.View { if (newZoneState != _lastZoneState) { if (newZoneState == -1) { - // single short vibration - // single pulse (placeholder) - System.println("[vibe] single pulse (below min)"); + // Below minimum - start alert cycle + _alertStartTime = System.getTimer(); + _lastAlertTime = System.getTimer(); + triggerSingleVibration(); } else if (newZoneState == 1) { - // double short vibration: second pulse scheduled - // first pulse (placeholder) - System.println("[vibe] first pulse (above max)"); - _vibeTimer.start(method(:_secondVibe), 240, false); + // Above maximum - start alert cycle + _alertStartTime = System.getTimer(); + _lastAlertTime = System.getTimer(); + triggerDoubleVibration(); + } else { + // Back in zone - stop alerts + _alertStartTime = null; + _lastAlertTime = 0; } _lastZoneState = newZoneState; + } else { + // Still out of zone - check if we need to alert again + checkAndTriggerAlerts(); } if (info != null && info.currentHeartRate != null){ @@ -198,4 +264,16 @@ class SimpleView extends WatchUi.View { } + // Load your resources here + function onLayout(dc as Dc) as Void { + setLayout(Rez.Layouts.MainLayout(dc)); + _cadenceDisplay = findDrawableById("cadence_text"); + _cadenceZoneDisplay = findDrawableById("cadence_zone"); + _heartrateDisplay = findDrawableById("heartrate_text"); + _distanceDisplay = findDrawableById("distance_text"); + _timeDisplay = findDrawableById("time_text"); + _cqDisplay = findDrawableById("cq_text"); + _hardcoreDisplay = findDrawableById("hardcore_text"); + } + }