3/4 View of the stretch timer powered on with all components exposed.
This project helped me build a stretch timer that organized stretch routines for me to follow, and provide audio queues for the completion of each stretch. The build consists of a battery-powered Arduino Mega with a 4" TFT display and dual rotary encoders. Features include three preset routines (Morning/Evening/Quick) with adjustable durations. To navigate stretches, two rotary encoders serve as the inputs. Double-click to start, where the screen displays a countdown timer with a progress bar. The stretch time includes audio cues via a transistor-amplified speaker, and any routine can be ended through an emergency triple-click quit.
This is a top-down view of all the components and their layout. The LCD screen wires are ziptied to help organize and conserve space. The arduino mega is placed in the middle to give all other parts an easy access to its pins.
Here is a close-up of the speaker circuit. A black wire connects the ground strip to the transistor emitter to serve as the return path. From the arduino pin 11, a yellow wire connects to a 1k ohm resistor as the control signal. Finally, a red wire connects the 5V strip to a 100 ohm resistor (to protect the speaker), and the resistor connects to one leg of the speaker. The other leg is connected to the collector leg of the transistor.
Here is a close up of the wires used to connect a rotary encoder. They are color coded based on the input they are fulfilling.
Here is a closeup of the color coded wires for the display. The inputs are all coming in from the right, and are color coded to each function. Specifically, the LED and 5V are red, since both of them affect the dimming of the screen, so it helps with troubleshooting
In this video, I show how to start a stretch routine, as well as the quit feature.
In this video, I show how to navigate through the different routines, using the right rotary encoder.
In this video, I show how to adjust the duration of stretches in each routine.
The initial planning for the size of the screen (since it needed to be ordered) referenced the size of the breadboard. Originally I thought I would use an Aruino Micro (to help with the size factor), however, the memory of the Arudino Mega seemed more advantageous, as well as having more pin inputs, which was neccerary for having more components.
By placing the two rotary encoders next to the breadboard, I imagine the general width needed for form the shape for the stretch timer.
Here, I finally get the UI online. This is after troubleshooting the wiring of the display and rotary encoders, especially when getting enough space for 5v and ground requirements. In this image specifically, I needed to orient the screen differently, since I expected it to have a landscape orientation. But this was a huge milestone in the development of the timer.
In this image, I show the zip-tied cables for my display and rotary encoder wiring. This was an attempt to organize the wiring of the overall device, so I wouldn't need to shift too many things around.
This is image serves as a reference for the scale of my device with all components spread out. The goal for the future is to shrink the size of my stretch timer.
When it came to troubleshooting the wiring, a lot of time was spent making sure things were plugged in the right place. Color coding helped immensely. A lot of this project was guided by Claude AI, so translating the instructions into actions wasn't too bad. However, I was able to reverse engineer the learning of how the device should be wired, so by the end of the project, I was pretty comfortable troubleshooting when the screen went blank. This confidence was also supplemented by the software, which was also written by Claude. This was especially important because knowing the software worked served as the foundation for troubleshooting the hardware.
Throughout the process of developing my stretch timer, I received a lot of feedback. During the initial crit, I was pretty quick to commit to the idea of making a stretch timer. Through my ideation, I was also able to determine early the components required to assemble the device. So a lot of it relied on understanding the interaction of how these components came together. For example, the software end was initially quite difficult, especially as I tried to iterate through ChatGPT. Lots of prompts I gave it didn't result in functional code. It was during this time that I received a lot of feedback from my peers, where the example demonstrating the functionality was not the best reflection on how I expected the interaction to go. For example, during the in-class crit, Estee's comment, "Maybe making the movements transition more smoothly would be helpful" summarizes the main problem of trying to deal with the refresh rate of my screen. This was something I implemented a lot, where I got Claude to iterate on the software to locally refresh the visuals to save time, as well as incorporating this refresh rate into the timer. For my final crit, I found a comment by Zach to be very helpful "You can save memory that will survive the Arduino restarting using an on-board facility called EEPROM; this would be sufficient to allow you to store the individual stretch timing from one session to the next." This would be useful since I could see adjusting the time constantly to be a big hassle.
Besides my peers' critiques, I definitely felt there were things I could improve with my project. I am very, very happy with how the functionality turned out. It functions exactly as I expected it to (except for a few interactions I forgot to consider). However, keeping in mind the small amount of prior experience and time I had for this project, I am satisfied. However, my main regret is not developing a polished enclosure for my stretch timer, especially as my main discipline is industrial design. However, having the functional aspect solidified, I feel very confident in developing the timer's form. I am excited to use it in the future!
In reflection on my critique and project as a whole, I definitely improved in confidence when it came to iterating with Arduino components. I got used to combing libraries and parts for resources. Furthermore, I really learned how to iterate on software with AI, using Claude to fully write my software end. This is very exciting since coding was the field I lacked the most in. I look forward to integrating AI's skillset more into my process. I would definitely try to work on this project a bit more consistently, rather in sprints; however, due to the Halloweekend flu taking me out for a whole weekend, I am happy with how I pulled through. I would recommend my past self to look into display refresh rates, so that it wouldn't hinder any visual animations required for the screen.
Looking forward, I fully expect to continue this project, especially since the part I am most qualified for is not yet fleshed out (the form and interaction). I will try to consolidate the wiring of parts to be smaller (to fit in any enclosure I develop)
Here is the electronic schematic for device.
/*
Luke's Stretch Timer Code
The code orchestrates a multi-state stretch timer interface with four operational modes: Browsing (navigate tabs and select stretches), Routine (run countdown timers with auto-advance), Adjusting (modify stretch durations), and Paused (freeze timer). It implements localized display refresh optimization, updating only changed screen elements (~6,000 pixels vs. 200,000 full-screen) for real-time accuracy. Multi-click detection distinguishes between double-clicks (start routine), triple-clicks (emergency quit), and single presses (mode-specific actions). The encoder library provides smooth navigation while non-blocking timers ensure the display remains responsive. Audio feedback uses tone() functions through a transistor-amplified speaker circuit. All timing starts only after screen rendering completes, ensuring accurate countdown precision.
This code was fully written by Claude AI: where the prompt described the expected interactions of the stretch timer.
Pin 2 → Tab Encoder CLK
Pin 3 → Tab Encoder DT
Pin 4 → Tab Encoder SW (Button)
Pin 7 → Display LED (Backlight)
Pin 8 → Display RST (Reset)
Pin 9 → Display DC (Data/Command)
Pin 10 → Display CS (Chip Select)
Pin 11 → Speaker Control Pin 18 → Selection Encoder CLK
Pin 19 → Selection Encoder DT
Pin 20 → Selection Encoder SW (Button)
Pin 50 → Display MISO (optional)
Pin 51 → Display MOSI
Pin 52 → Display SCK
/*
* Portable Stretch Routine Interface - COMPLETE FEATURE SET
*
* Hardware: Arduino Mega 2560 + MSP4020/MSP4021 4" TFT + 2 Rotary Encoders + Speaker
* Power: 9V Battery with on/off switch
*
* ═══════════════════════════════════════════════════════════════════════════
* COMPLETE FEATURE LIST (ALL IMPLEMENTED):
* ═══════════════════════════════════════════════════════════════════════════
*
* ✅ NAVIGATION:
* • Encoder 1: Navigate between tabs (Morning/Evening/Quick routines)
* • Encoder 2: Select stretches within current tab
* • Visual highlight shows current selection
* • Smooth scrolling with optimization
*
* ✅ ROUTINE EXECUTION:
* • Double-click any encoder button to start
* • Large countdown timer (updates every second)
* • Progress bar shows completion
* • Auto-advance to next stretch at zero
* • Timer only starts AFTER screen fully loads (accurate timing)
*
* ✅ DURATION ADJUSTMENT:
* • Single Tab button press enters adjust mode
* • Turn Encoder 2 to change duration (5-second increments)
* • Range: 5 seconds to 5 minutes (300 seconds)
* • Single Tab button saves and exits
*
* ✅ EMERGENCY CONTROLS:
* • Triple-click ANY encoder: Emergency quit to browsing
* • Works from ANY state (routine, paused, adjusting)
* • Shows red "QUIT" confirmation message
*
* ✅ ROUTINE CONTROLS:
* • Single Tab button during routine: Skip to next stretch
* • Single Selection button during pause: Stop and return to browsing
* • Automatic completion screen at end
*
* ✅ AUDIO FEEDBACK:
* • Startup tone (3 ascending beeps)
* • Start tone when routine begins
* • Tick tone at 3-2-1 countdown
* • Pause tone for quit/pause
* • Select tone for menu actions
* • Completion tone (victory fanfare)
*
* ✅ DISPLAY OPTIMIZATION:
* • Localized refresh (only updates changed elements)
* • Timer updates: ~6,000 pixels (50ms refresh)
* • Browsing highlight: ~4,000 pixels (30ms refresh)
* • Adjust mode: Number only updates
* • 20-60x faster than full screen redraws
*
* ✅ MULTIPLE ROUTINES:
* • Morning Routine: 7 stretches (3:55 total)
* • Evening Routine: 6 stretches (5:15 total)
* • Quick Stretch: 5 stretches (1:30 total)
* • Each with custom colors and durations
*
* ✅ USER INTERFACE:
* • Landscape orientation (480x320)
* • Color-coded tabs
* • Context-sensitive footer instructions
* • Stretch counter (e.g., "Stretch 2 of 7")
* • Total routine time display
* • Scroll indicator for long lists
*
* ✅ POWER MANAGEMENT:
* • 9V battery powered
* • On/off switch control
* • Efficient display updates minimize power
*
* ═══════════════════════════════════════════════════════════════════════════
* CONTROLS SUMMARY:
* ═══════════════════════════════════════════════════════════════════════════
*
* BROWSING MODE:
* • Turn Encoder 1: Navigate tabs
* • Turn Encoder 2: Select stretches
* • Double-click any encoder: START routine
* • Single Tab button: Enter adjust mode
*
* ROUTINE MODE (Timer Running):
* • Timer counts down automatically
* • Single Tab button: Skip to next stretch
* • Triple-click: Emergency quit
*
* ADJUST MODE:
* • Turn Encoder 2: Adjust duration (±5 seconds)
* • Single Tab button: Save and exit
* • Triple-click: Emergency quit
*
* PAUSED MODE:
* • Single Tab button: Resume
* • Single Selection button: Stop routine
* • Triple-click: Emergency quit
*
* ANY TIME:
* • Triple-click ANY encoder: Emergency quit to browsing
*
* ═══════════════════════════════════════════════════════════════════════════
*/
#include <LCDWIKI_SPI.h>
#include <Encoder.h>
// ============================================================================
// HARDWARE CONFIGURATION
// ============================================================================
// Display pins (Hardware SPI)
#define TFT_CS 10
#define TFT_DC 9
#define TFT_RST 8
#define TFT_LED 7
// Create display object - Using ST7796S driver
LCDWIKI_SPI tft(ST7796S, TFT_CS, TFT_DC, TFT_RST, TFT_LED);
// Encoder pins
#define ENC_TAB_CLK 2 // Tab navigation
#define ENC_TAB_DT 3
#define ENC_TAB_SW 4
#define ENC_SEL_CLK 18 // Stretch selection
#define ENC_SEL_DT 19
#define ENC_SEL_SW 20
Encoder encoderTab(ENC_TAB_CLK, ENC_TAB_DT);
Encoder encoderSel(ENC_SEL_CLK, ENC_SEL_DT);
// Speaker
#define SPEAKER_PIN 11
// Display dimensions - LANDSCAPE orientation
#define SCREEN_WIDTH 480
#define SCREEN_HEIGHT 320
// Colors (RGB565)
#define COLOR_BLACK 0x0000
#define COLOR_WHITE 0xFFFF
#define COLOR_RED 0xF800
#define COLOR_GREEN 0x07E0
#define COLOR_BLUE 0x001F
#define COLOR_CYAN 0x07FF
#define COLOR_YELLOW 0xFFE0
#define COLOR_ORANGE 0xFD20
#define COLOR_GRAY 0x8410
#define COLOR_DARKGRAY 0x4208
#define COLOR_LIGHTGRAY 0xC618
#define COLOR_PURPLE 0x780F
// ============================================================================
// DATA STRUCTURES
// ============================================================================
struct Stretch {
const char* name;
int duration;
};
struct RoutineTab {
const char* name;
uint16_t color;
Stretch* stretches;
int stretchCount;
};
// Morning routine stretches
Stretch morningStretches[] = {
{"Neck Rolls", 30},
{"Shoulder Shrugs", 20},
{"Arm Circles", 30},
{"Side Bends", 40},
{"Hamstring Stretch", 45},
{"Quad Stretch", 40},
{"Calf Raises", 30}
};
// Evening routine stretches
Stretch eveningStretches[] = {
{"Cat-Cow Stretch", 40},
{"Child's Pose", 60},
{"Spinal Twist", 50},
{"Hip Flexor Stretch", 45},
{"Seated Forward Fold", 60},
{"Butterfly Stretch", 40}
};
// Quick routine stretches
Stretch quickStretches[] = {
{"Neck Stretch", 20},
{"Shoulder Rolls", 15},
{"Wrist Circles", 15},
{"Standing Twist", 20},
{"Toe Touches", 20}
};
// Define all tabs
RoutineTab tabs[] = {
{"Morning Routine", COLOR_ORANGE, morningStretches, 7},
{"Evening Routine", COLOR_PURPLE, eveningStretches, 6},
{"Quick Stretch", COLOR_GREEN, quickStretches, 5}
};
const int tabCount = 3;
// ============================================================================
// APPLICATION STATE
// ============================================================================
int currentTab = 0;
int currentStretch = 0;
int lastTab = -1;
int lastStretch = -1;
long lastEncTab = 0;
long lastEncSel = 0;
bool btnTab_pressed = false;
bool btnSel_pressed = false;
unsigned long lastDebounceTab = 0;
unsigned long lastDebounceSel = 0;
const unsigned long debounceDelay = 50;
// Multi-click detection
int tabClickCount = 0;
int selClickCount = 0;
unsigned long lastTabClick = 0;
unsigned long lastSelClick = 0;
const unsigned long multiClickWindow = 500;
enum SystemState {
STATE_BROWSING,
STATE_ROUTINE,
STATE_PAUSED,
STATE_ADJUSTING
};
SystemState systemState = STATE_BROWSING;
unsigned long routineStartTime = 0;
unsigned long stretchStartTime = 0;
int activeStretchIndex = 0;
int lastRemainingSeconds = -1;
bool routineComplete = false;
bool needsFullRedraw = true;
unsigned long lastDisplayUpdate = 0;
const unsigned long displayUpdateInterval = 100;
int lastDisplayedSeconds = -1;
char lastTimerString[8] = "";
int lastProgressWidth = -1;
int lastHighlightY = -1;
int lastScrollOffset = -1;
int lastAdjustDuration = -1;
// ============================================================================
// SETUP
// ============================================================================
void setup() {
Serial.begin(115200);
Serial.println(F("\n========================================"));
Serial.println(F("Stretch Routine Interface v2.0"));
Serial.println(F("========================================\n"));
pinMode(ENC_TAB_SW, INPUT_PULLUP);
pinMode(ENC_SEL_SW, INPUT_PULLUP);
pinMode(SPEAKER_PIN, OUTPUT);
pinMode(TFT_LED, OUTPUT);
digitalWrite(TFT_LED, HIGH);
Serial.println(F("Initializing display..."));
tft.Init_LCD();
delay(100);
tft.Set_Rotation(3);
tft.Fill_Screen(COLOR_BLACK);
playStartupTone();
drawBrowsingInterface();
Serial.println(F("Ready!\n"));
Serial.println(F("CONTROLS:"));
Serial.println(F("─────────────────────────────────────"));
Serial.println(F("BROWSING MODE:"));
Serial.println(F(" • Turn Encoder 1: Switch tabs"));
Serial.println(F(" • Turn Encoder 2: Select stretches"));
Serial.println(F(" • Double-click ANY encoder: Start"));
Serial.println(F(" • Single Tab button: Adjust duration"));
Serial.println(F(""));
Serial.println(F("ROUTINE MODE:"));
Serial.println(F(" • Timer counts down automatically"));
Serial.println(F(" • Single Tab button: Skip stretch"));
Serial.println(F(" • Triple-click: Emergency quit"));
Serial.println(F(""));
Serial.println(F("ADJUST MODE:"));
Serial.println(F(" • Turn Encoder 2: Change duration"));
Serial.println(F(" • Single Tab button: Save & exit"));
Serial.println(F(""));
Serial.println(F("ANY TIME:"));
Serial.println(F(" • Triple-click ANY encoder: Quit"));
Serial.println(F("─────────────────────────────────────\n"));
}
// ============================================================================
// MAIN LOOP
// ============================================================================
void loop() {
handleEncoders();
handleButtons();
switch (systemState) {
case STATE_BROWSING:
updateBrowsingDisplay();
break;
case STATE_ROUTINE:
updateRoutineTimer();
break;
case STATE_PAUSED:
break;
case STATE_ADJUSTING:
updateAdjustingDisplay();
break;
}
delay(2);
}
// ============================================================================
// INPUT HANDLING
// ============================================================================
void handleEncoders() {
long newEncTab = encoderTab.read();
long newEncSel = encoderSel.read();
if (systemState == STATE_BROWSING) {
if (newEncTab != lastEncTab) {
long delta = (newEncTab - lastEncTab) / 4;
if (delta != 0) {
currentTab += delta;
currentTab = constrain(currentTab, 0, tabCount - 1);
if (currentTab != lastTab) {
currentStretch = 0;
lastTab = currentTab;
needsFullRedraw = true;
}
lastEncTab = newEncTab;
}
}
if (newEncSel != lastEncSel) {
long delta = (newEncSel - lastEncSel) / 4;
if (delta != 0) {
currentStretch += delta;
int maxStretch = tabs[currentTab].stretchCount - 1;
currentStretch = constrain(currentStretch, 0, maxStretch);
lastEncSel = newEncSel;
}
}
}
else if (systemState == STATE_ADJUSTING) {
if (newEncSel != lastEncSel) {
long delta = (newEncSel - lastEncSel) / 4;
if (delta != 0) {
Stretch* stretch = &tabs[currentTab].stretches[currentStretch];
stretch->duration += delta * 5;
stretch->duration = constrain(stretch->duration, 5, 300);
lastEncSel = newEncSel;
}
}
}
}
void handleButtons() {
bool tabDown = (digitalRead(ENC_TAB_SW) == LOW);
bool selDown = (digitalRead(ENC_SEL_SW) == LOW);
unsigned long currentTime = millis();
// Tab button
if (tabDown && !btnTab_pressed && (currentTime - lastDebounceTab > debounceDelay)) {
btnTab_pressed = true;
lastDebounceTab = currentTime;
if (currentTime - lastTabClick < multiClickWindow) {
tabClickCount++;
if (tabClickCount >= 3) {
emergencyQuit();
tabClickCount = 0;
return;
}
if (tabClickCount == 2 && systemState == STATE_BROWSING) {
Serial.println(F("DOUBLE-CLICK START"));
startRoutine();
tabClickCount = 0;
return;
}
} else {
tabClickCount = 1;
}
lastTabClick = currentTime;
}
if (!tabDown) btnTab_pressed = false;
// Selection button
if (selDown && !btnSel_pressed && (currentTime - lastDebounceSel > debounceDelay)) {
btnSel_pressed = true;
lastDebounceSel = currentTime;
if (currentTime - lastSelClick < multiClickWindow) {
selClickCount++;
if (selClickCount >= 3) {
emergencyQuit();
selClickCount = 0;
return;
}
if (selClickCount == 2 && systemState == STATE_BROWSING) {
Serial.println(F("DOUBLE-CLICK START"));
startRoutine();
selClickCount = 0;
return;
}
} else {
selClickCount = 1;
}
lastSelClick = currentTime;
}
if (!selDown) btnSel_pressed = false;
// Single press actions after window expires
if (currentTime - lastTabClick > multiClickWindow && tabClickCount == 1) {
onTabButtonPress();
tabClickCount = 0;
}
if (currentTime - lastSelClick > multiClickWindow && selClickCount == 1) {
onSelButtonPress();
selClickCount = 0;
}
}
void onTabButtonPress() {
if (systemState == STATE_BROWSING) {
systemState = STATE_ADJUSTING;
needsFullRedraw = true;
lastAdjustDuration = -1;
playSelectTone();
Serial.println(F("→ Entering adjust mode"));
}
else if (systemState == STATE_ADJUSTING) {
systemState = STATE_BROWSING;
needsFullRedraw = true;
playSelectTone();
Serial.println(F("→ Saved and exiting adjust mode"));
}
else if (systemState == STATE_ROUTINE) {
// Skip to next stretch
Serial.println(F("→ Skipping to next stretch"));
nextStretch();
}
else if (systemState == STATE_PAUSED) {
systemState = STATE_ROUTINE;
playSelectTone();
Serial.println(F("→ Resuming routine"));
}
}
void onSelButtonPress() {
if (systemState == STATE_ROUTINE) {
// Also allow skipping with selection button
Serial.println(F("→ Skipping to next stretch (Selection)"));
nextStretch();
}
else if (systemState == STATE_PAUSED) {
systemState = STATE_BROWSING;
needsFullRedraw = true;
playSelectTone();
Serial.println(F("→ Stopping routine, returning to browsing"));
}
// Browsing and Adjusting modes: no single-press action for Selection button
}
void emergencyQuit() {
Serial.println(F("TRIPLE-CLICK QUIT"));
playPauseTone();
systemState = STATE_BROWSING;
needsFullRedraw = true;
routineComplete = false;
tft.Fill_Screen(COLOR_BLACK);
tft.Set_Text_Mode(0);
tft.Set_Text_Size(4);
tft.Set_Text_colour(COLOR_RED);
tft.Set_Text_Back_colour(COLOR_BLACK);
tft.Print_String("QUIT", (SCREEN_WIDTH - 96) / 2, 140);
delay(500);
}
// ============================================================================
// ROUTINE CONTROL
// ============================================================================
void startRoutine() {
Serial.println(F("=== START ROUTINE CALLED ==="));
systemState = STATE_ROUTINE;
activeStretchIndex = 0;
routineComplete = false;
lastRemainingSeconds = -1;
needsFullRedraw = true;
lastDisplayedSeconds = -1;
lastTimerString[0] = '\0';
lastProgressWidth = -1;
playStartTone();
Serial.print(F("Starting: "));
Serial.println(tabs[currentTab].name);
Serial.print(F("First stretch: "));
Serial.println(tabs[currentTab].stretches[0].name);
Serial.print(F("Duration: "));
Serial.println(tabs[currentTab].stretches[0].duration);
// Set to 0 - will be set after screen draws
routineStartTime = 0;
stretchStartTime = 0;
Serial.println(F("Waiting for screen to draw..."));
}
void nextStretch() {
activeStretchIndex++;
if (activeStretchIndex >= tabs[currentTab].stretchCount) {
completeRoutine();
} else {
needsFullRedraw = true;
lastRemainingSeconds = -1;
lastDisplayedSeconds = -1;
lastTimerString[0] = '\0';
lastProgressWidth = -1;
playStartTone();
stretchStartTime = 0;
}
}
void completeRoutine() {
routineComplete = true;
playCompleteTone();
drawCompletionScreen();
delay(3000);
systemState = STATE_BROWSING;
needsFullRedraw = true;
}
void updateRoutineTimer() {
if (routineComplete) return;
// First call - draw the screen before starting timer
if (stretchStartTime == 0) {
Stretch* currentStretchData = &tabs[currentTab].stretches[activeStretchIndex];
// Draw with full duration to initialize screen
drawRoutineTimer(currentStretchData->duration, currentStretchData->duration);
return; // Timer will start in drawRoutineTimer
}
Stretch* currentStretchData = &tabs[currentTab].stretches[activeStretchIndex];
unsigned long elapsed = (millis() - stretchStartTime) / 1000;
int remaining = currentStretchData->duration - elapsed;
if (remaining <= 0) {
nextStretch();
} else if (remaining != lastRemainingSeconds) {
drawRoutineTimer(remaining, currentStretchData->duration);
lastRemainingSeconds = remaining;
if (remaining <= 3 && remaining > 0) {
playTickTone();
}
}
}
// ============================================================================
// DISPLAY FUNCTIONS
// ============================================================================
void drawBrowsingInterface() {
tft.Fill_Screen(COLOR_BLACK);
drawHeader();
drawStretchList();
drawFooter("2x: Start | 3x: Quit | Tab: Adjust", COLOR_DARKGRAY);
needsFullRedraw = false;
lastTab = currentTab;
lastStretch = currentStretch;
lastHighlightY = -1;
lastScrollOffset = -1;
}
void updateBrowsingDisplay() {
if (millis() - lastDisplayUpdate < displayUpdateInterval && !needsFullRedraw) return;
if (currentTab != lastTab || currentStretch != lastStretch || needsFullRedraw) {
if (needsFullRedraw) {
drawBrowsingInterface();
} else if (currentTab != lastTab) {
drawHeader();
drawStretchList();
lastTab = currentTab;
lastHighlightY = -1;
lastScrollOffset = -1;
} else if (currentStretch != lastStretch) {
drawStretchListOptimized();
lastStretch = currentStretch;
}
lastDisplayUpdate = millis();
}
}
void drawHeader() {
tft.Set_Draw_color(tabs[currentTab].color);
tft.Fill_Rectangle(0, 0, SCREEN_WIDTH - 1, 50);
tft.Set_Text_Mode(0);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
tft.Set_Text_Back_colour(tabs[currentTab].color);
int nameLen = strlen(tabs[currentTab].name);
tft.Print_String(tabs[currentTab].name, (SCREEN_WIDTH - nameLen * 12) / 2, 10);
tft.Set_Text_Size(1);
char tabInfo[16];
sprintf(tabInfo, "Tab %d/%d", currentTab + 1, tabCount);
tft.Print_String(tabInfo, 10, 35);
int totalTime = calculateTotalTime();
char timeStr[16];
sprintf(timeStr, "%d:%02d", totalTime / 60, totalTime % 60);
tft.Print_String(timeStr, SCREEN_WIDTH - 60, 35);
}
void drawStretchList() {
int listY = 60;
int itemHeight = 35;
int visibleItems = 6;
tft.Set_Draw_color(COLOR_BLACK);
tft.Fill_Rectangle(0, listY, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 40);
RoutineTab* tab = &tabs[currentTab];
int scrollOffset = 0;
if (currentStretch >= visibleItems / 2) {
scrollOffset = currentStretch - visibleItems / 2;
}
if (scrollOffset + visibleItems > tab->stretchCount) {
scrollOffset = tab->stretchCount - visibleItems;
}
if (scrollOffset < 0) scrollOffset = 0;
lastScrollOffset = scrollOffset;
tft.Set_Text_Mode(0);
for (int i = 0; i < visibleItems && (i + scrollOffset) < tab->stretchCount; i++) {
int idx = i + scrollOffset;
Stretch* stretch = &tab->stretches[idx];
int y = listY + i * itemHeight;
if (idx == currentStretch) {
tft.Set_Draw_color(COLOR_DARKGRAY);
tft.Fill_Rectangle(5, y, SCREEN_WIDTH - 5, y + itemHeight - 2);
lastHighlightY = y;
}
tft.Set_Text_Size(2);
tft.Set_Text_colour(idx == currentStretch ? COLOR_YELLOW : COLOR_WHITE);
tft.Set_Text_Back_colour(idx == currentStretch ? COLOR_DARKGRAY : COLOR_BLACK);
tft.Print_String(stretch->name, 15, y + 2);
tft.Set_Text_Size(1);
tft.Set_Text_colour(COLOR_CYAN);
char durStr[16];
sprintf(durStr, "%d sec", stretch->duration);
tft.Print_String(durStr, 15, y + 20);
}
if (tab->stretchCount > visibleItems) {
int scrollBarHeight = (visibleItems * 50) / tab->stretchCount;
int scrollBarY = listY + (scrollOffset * (visibleItems * itemHeight)) / tab->stretchCount;
tft.Set_Draw_color(COLOR_GRAY);
tft.Fill_Rectangle(SCREEN_WIDTH - 8, scrollBarY, SCREEN_WIDTH - 3, scrollBarY + scrollBarHeight);
}
}
void drawStretchListOptimized() {
int listY = 60;
int itemHeight = 35;
int visibleItems = 6;
RoutineTab* tab = &tabs[currentTab];
int scrollOffset = 0;
if (currentStretch >= visibleItems / 2) {
scrollOffset = currentStretch - visibleItems / 2;
}
if (scrollOffset + visibleItems > tab->stretchCount) {
scrollOffset = tab->stretchCount - visibleItems;
}
if (scrollOffset < 0) scrollOffset = 0;
if (scrollOffset != lastScrollOffset) {
drawStretchList();
return;
}
tft.Set_Text_Mode(0);
if (lastHighlightY >= 0) {
int oldIdx = lastStretch - scrollOffset;
if (oldIdx >= 0 && oldIdx < visibleItems) {
int y = listY + oldIdx * itemHeight;
tft.Set_Draw_color(COLOR_BLACK);
tft.Fill_Rectangle(5, y, SCREEN_WIDTH - 5, y + itemHeight - 2);
Stretch* oldStretch = &tab->stretches[lastStretch];
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
tft.Set_Text_Back_colour(COLOR_BLACK);
tft.Print_String(oldStretch->name, 15, y + 2);
tft.Set_Text_Size(1);
tft.Set_Text_colour(COLOR_CYAN);
char durStr[16];
sprintf(durStr, "%d sec", oldStretch->duration);
tft.Print_String(durStr, 15, y + 20);
}
}
int newIdx = currentStretch - scrollOffset;
if (newIdx >= 0 && newIdx < visibleItems) {
int y = listY + newIdx * itemHeight;
tft.Set_Draw_color(COLOR_DARKGRAY);
tft.Fill_Rectangle(5, y, SCREEN_WIDTH - 5, y + itemHeight - 2);
Stretch* newStretch = &tabs[currentTab].stretches[currentStretch];
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_YELLOW);
tft.Set_Text_Back_colour(COLOR_DARKGRAY);
tft.Print_String(newStretch->name, 15, y + 2);
tft.Set_Text_Size(1);
tft.Set_Text_colour(COLOR_CYAN);
tft.Set_Text_Back_colour(COLOR_DARKGRAY);
char durStr[16];
sprintf(durStr, "%d sec", newStretch->duration);
tft.Print_String(durStr, 15, y + 20);
lastHighlightY = y;
}
lastScrollOffset = scrollOffset;
}
void updateAdjustingDisplay() {
Stretch* stretch = &tabs[currentTab].stretches[currentStretch];
if (needsFullRedraw) {
tft.Fill_Screen(COLOR_BLACK);
tft.Set_Draw_color(tabs[currentTab].color);
tft.Fill_Rectangle(0, 0, SCREEN_WIDTH - 1, 50);
tft.Set_Text_Mode(0);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
tft.Set_Text_Back_colour(tabs[currentTab].color);
tft.Print_String("Adjust Duration", (SCREEN_WIDTH - 180) / 2, 15);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_YELLOW);
tft.Set_Text_Back_colour(COLOR_BLACK);
int nameWidth = strlen(stretch->name) * 12;
tft.Print_String(stretch->name, (SCREEN_WIDTH - nameWidth) / 2, 90);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
tft.Print_String("seconds", (SCREEN_WIDTH - 84) / 2, 220);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_LIGHTGRAY);
tft.Print_String("Turn to adjust", (SCREEN_WIDTH - 168) / 2, SCREEN_HEIGHT - 50);
tft.Print_String("Press to save", (SCREEN_WIDTH - 156) / 2, SCREEN_HEIGHT - 25);
needsFullRedraw = false;
lastAdjustDuration = -1;
}
if (stretch->duration != lastAdjustDuration) {
char durStr[16];
sprintf(durStr, "%d", stretch->duration);
int durWidth = strlen(durStr) * 48;
int durX = (SCREEN_WIDTH - durWidth) / 2;
int durY = 140;
if (lastAdjustDuration >= 0) {
char oldStr[16];
sprintf(oldStr, "%d", lastAdjustDuration);
int oldWidth = strlen(oldStr) * 48;
int oldX = (SCREEN_WIDTH - oldWidth) / 2;
tft.Set_Draw_color(COLOR_BLACK);
tft.Fill_Rectangle(oldX - 5, durY, oldX + oldWidth + 5, durY + 64);
}
tft.Set_Text_Mode(0);
tft.Set_Text_Size(8);
tft.Set_Text_colour(COLOR_CYAN);
tft.Set_Text_Back_colour(COLOR_BLACK);
tft.Print_String(durStr, durX, durY);
lastAdjustDuration = stretch->duration;
}
}
void drawRoutineTimer(int remainingSeconds, int totalSeconds) {
Stretch* stretch = &tabs[currentTab].stretches[activeStretchIndex];
if (needsFullRedraw) {
Serial.println(F("Drawing routine screen..."));
tft.Fill_Screen(COLOR_BLACK);
// Header
tft.Set_Draw_color(tabs[currentTab].color);
tft.Fill_Rectangle(0, 0, SCREEN_WIDTH - 1, 40);
tft.Set_Text_Mode(0);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
tft.Set_Text_Back_colour(tabs[currentTab].color);
int nameLen = strlen(tabs[currentTab].name);
tft.Print_String(tabs[currentTab].name, (SCREEN_WIDTH - nameLen * 12) / 2, 12);
// Stretch name
tft.Set_Text_Size(3);
tft.Set_Text_colour(COLOR_YELLOW);
tft.Set_Text_Back_colour(COLOR_BLACK);
int stretchNameLen = strlen(stretch->name);
tft.Print_String(stretch->name, (SCREEN_WIDTH - stretchNameLen * 18) / 2, 60);
// Progress bar background
int barWidth = SCREEN_WIDTH - 80;
int barHeight = 25;
int barX = 40;
int barY = 210;
tft.Set_Draw_color(COLOR_DARKGRAY);
tft.Fill_Rectangle(barX, barY, barX + barWidth, barY + barHeight);
// Progress text
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
char progressStr[32];
sprintf(progressStr, "Stretch %d of %d", activeStretchIndex + 1, tabs[currentTab].stretchCount);
int progLen = strlen(progressStr);
tft.Print_String(progressStr, (SCREEN_WIDTH - progLen * 12) / 2, 250);
// Footer
drawFooter("Tab: Skip | 3x: Quit", COLOR_DARKGRAY);
needsFullRedraw = false;
lastDisplayedSeconds = -1;
lastProgressWidth = -1;
// NOW start the timer
if (stretchStartTime == 0) {
stretchStartTime = millis();
if (routineStartTime == 0) {
routineStartTime = millis();
}
Serial.println(F("Timer started!"));
Serial.print(F("Starting at: "));
Serial.println(remainingSeconds);
}
}
// Update timer number
if (remainingSeconds != lastDisplayedSeconds) {
char timeStr[8];
sprintf(timeStr, "%d", remainingSeconds);
int timeWidth = strlen(timeStr) * 48;
int timeX = (SCREEN_WIDTH - timeWidth) / 2;
int timeY = 120;
if (lastDisplayedSeconds >= 0) {
int oldWidth = strlen(lastTimerString) * 48;
int oldX = (SCREEN_WIDTH - oldWidth) / 2;
tft.Set_Draw_color(COLOR_BLACK);
tft.Fill_Rectangle(oldX - 5, timeY, oldX + oldWidth + 5, timeY + 64);
}
tft.Set_Text_Mode(0);
tft.Set_Text_Size(8);
tft.Set_Text_colour(remainingSeconds <= 5 ? COLOR_RED : COLOR_GREEN);
tft.Set_Text_Back_colour(COLOR_BLACK);
tft.Print_String(timeStr, timeX, timeY);
strcpy(lastTimerString, timeStr);
lastDisplayedSeconds = remainingSeconds;
}
// Update progress bar
int barWidth = SCREEN_WIDTH - 80;
int barX = 40;
int barY = 210;
int barHeight = 25;
int progress = ((totalSeconds - remainingSeconds) * barWidth) / totalSeconds;
if (progress != lastProgressWidth) {
if (progress > lastProgressWidth) {
tft.Set_Draw_color(COLOR_GREEN);
int startX = (lastProgressWidth < 0) ? barX : barX + lastProgressWidth;
tft.Fill_Rectangle(startX, barY, barX + progress, barY + barHeight);
}
lastProgressWidth = progress;
}
}
void drawCompletionScreen() {
tft.Fill_Screen(COLOR_BLACK);
tft.Set_Text_Mode(0);
tft.Set_Text_Size(4);
tft.Set_Text_colour(COLOR_GREEN);
tft.Set_Text_Back_colour(COLOR_BLACK);
tft.Print_String("Complete!", 50, 120);
tft.Set_Text_Size(2);
tft.Set_Text_colour(COLOR_WHITE);
tft.Print_String("Great job!", 160, 180);
}
void drawFooter(const char* text, uint16_t bgColor) {
tft.Set_Draw_color(bgColor);
tft.Fill_Rectangle(0, SCREEN_HEIGHT - 35, SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1);
tft.Set_Text_Mode(0);
tft.Set_Text_Size(1);
tft.Set_Text_colour(COLOR_WHITE);
tft.Set_Text_Back_colour(bgColor);
int textLen = strlen(text);
int x = (SCREEN_WIDTH - textLen * 6) / 2;
tft.Print_String(text, x, SCREEN_HEIGHT - 25);
}
int calculateTotalTime() {
int total = 0;
RoutineTab* tab = &tabs[currentTab];
for (int i = 0; i < tab->stretchCount; i++) {
total += tab->stretches[i].duration;
}
return total;
}
// ============================================================================
// AUDIO FEEDBACK
// ============================================================================
void playStartupTone() {
tone(SPEAKER_PIN, 1000, 100);
delay(120);
tone(SPEAKER_PIN, 1200, 100);
delay(120);
tone(SPEAKER_PIN, 1400, 150);
delay(170);
}
void playStartTone() {
tone(SPEAKER_PIN, 800, 200);
delay(220);
tone(SPEAKER_PIN, 1200, 200);
delay(220);
}
void playSelectTone() {
tone(SPEAKER_PIN, 1000, 100);
delay(120);
}
void playTickTone() {
tone(SPEAKER_PIN, 1500, 100);
}
void playPauseTone() {
tone(SPEAKER_PIN, 600, 300);
}
void playCompleteTone() {
tone(SPEAKER_PIN, 800, 150);
delay(170);
tone(SPEAKER_PIN, 1000, 150);
delay(170);
tone(SPEAKER_PIN, 1200, 150);
delay(170);
tone(SPEAKER_PIN, 1600, 400);
delay(420);
}