Controlling your Treadmill from Silverlight - Part 3

Controlling your Treadmill from Silverlight - Part 1
Controlling your Treadmill from Silverlight - Part 2

In the final post for this series, we will be expanding the usefulness of our application by adding a workout feature.  This feature will allow you to craft workouts (in XML format of course) and then have the application execute them.  The application will render a nice visualization so you can monitor your progress as you burn off those calories.

Separation of Concerns
As with any application, complexity can be your worst enemy.  To keep your sanity, you need to manage your complexity so that you can easily maintain and expand your application in the future.  At this point in time, the easiest step would be to start plugging logic into our Silverlight Page Code-behind.  But this generally a bad idea for all but the most trivial of applications.  Instead, we will opt to create a TreadmillContrller class and a WorkoutController class.  The TreadmillController class will be responsible for playing the correct sounds to change the treadmill settings and the WorkoutController will be responsible for parsing the workout xml files and to instruct us when to change settings along our workout routine.  This class also, builds the workout graph that will be displayed on the main screen.  In retrospect, I should probably not be building the graph here, but it worked sufficiently well for this exercise.

TreadmillController Class

   1: public class TreadmillController
   2: {
   3: public const double MAX_INCLINE = 10;
   4: public const double MAX_SPEED = 10;
   5: public const double DELTA = 0.5;
   6:  
   7: private readonly MediaElement m_soundPlayer;
   8: private double m_currentSpeed;
   9: private double m_currentIncline;
  10:  
  11: public TreadmillController()
  12: {
  13:     m_soundPlayer = new MediaElement {Volume = 0.5};
  14: }
  15:  
  16: public bool ChangeTreadmillSettings(double speed, double incline)
  17: {
  18:     if(speed < 0 || incline < 0)
  19:         return false;
  20:     if(speed > MAX_SPEED || incline > MAX_INCLINE)
  21:         return false;
  22:  
  23:     string fileName = string.Format("{0}_{1}.mp3", speed * 10, incline * 10);
  24:     Uri resourceUri = new Uri("SilverTread;component/sounds/" + fileName, UriKind.Relative);
  25:     StreamResourceInfo sri = Application.GetResourceStream(resourceUri);
  26:     if (sri != null)
  27:     {
  28:         m_soundPlayer.SetSource(sri.Stream);
  29:     }
  30:     else
  31:     {
  32:         System.Diagnostics.Debug.WriteLine("File Not Found: " + fileName);
  33:     }
  34:  
  35:     m_currentSpeed = speed;
  36:     m_currentIncline = incline;
  37:  
  38:     return true;
  39: }
  40:  
  41: public double CurrentSpeed
  42: {
  43:     get { return m_currentSpeed; }
  44: }
  45:  
  46: public double CurrentIncline 
  47: {
  48:     get { return m_currentIncline; }
  49: }


WorkoutController Class

   1: public class WorkoutController
   2: {
   3:     private List<Checkpoint> m_checkpoints;
   4:     private Dictionary<int, UIElement> m_graphSegments;
   5:     private readonly DispatcherTimer m_timer;
   6:     private readonly IWorkoutInfoView m_workoutInfoView;
   7:  
   8:     private bool m_workoutLoaded;
   9:     private DateTime m_workoutStartTime;
  10:     private Rectangle progress;
  11:  
  12:     private int m_previousCheckpointIndex = -1;
  13:     private bool m_workoutStarted;
  14:     
  15:     public WorkoutController(IWorkoutInfoView workoutInfoView)
  16:     {
  17:         m_workoutInfoView = workoutInfoView;
  18:  
  19:         m_timer = new DispatcherTimer();
  20:         m_timer.Interval = new TimeSpan(0, 0, 0, 0, 500);
  21:         m_timer.Tick += new EventHandler(m_timer_Tick);
  22:     }
  23:  
  24:     public void LoadProgram(string uri)
  25:     {
  26:         XDocument doc = new XDocument();
  27:         doc = XDocument.Load(uri);
  28:         int i = 0;
  29:         var checkpoints = from checkpoint in doc.Descendants("workout").Descendants("checkpoint")
  30:                           select new Checkpoint()
  31:                           {
  32:                               Time = int.Parse(checkpoint.Attribute("time").Value),
  33:                               Speed = double.Parse(checkpoint.Attribute("speed").Value),
  34:                               Incline = double.Parse(checkpoint.Attribute("incline").Value),
  35:                               Index = i++
  36:                           };
  37:  
  38:         m_checkpoints = checkpoints.ToList();
  39:         m_workoutLoaded = true;
  40:  
  41:         BuildWorkoutGraph();
  42:     }
  43:  
  44:  
  45:     private void BuildWorkoutGraph()
  46:     {
  47:         m_graphSegments = new Dictionary<int, UIElement>();
  48:         int totalTime = (int)GetProgramDuration().TotalSeconds;
  49:         Grid graphGrid = new Grid();
  50:  
  51:         // Progress
  52:         progress = new Rectangle();
  53:         progress.Width = 0;
  54:         progress.Height = m_workoutInfoView.WorkoutGraphContainer.ActualHeight;
  55:         progress.Margin = new Thickness(5);
  56:         progress.Style = App.Current.Resources["ProgressGraphRectangle"] as Style;
  57:         progress.HorizontalAlignment = HorizontalAlignment.Left;
  58:  
  59:         // Speeds
  60:         StackPanel speedGraph = new StackPanel();
  61:         speedGraph.VerticalAlignment = VerticalAlignment.Bottom;
  62:         speedGraph.Orientation = Orientation.Horizontal;
  63:         speedGraph.Margin = new Thickness(5);
  64:         int i = 0;
  65:         foreach (var checkpoint in m_checkpoints)
  66:         {
  67:             Rectangle r = new Rectangle();
  68:             r.VerticalAlignment = VerticalAlignment.Bottom;
  69:             r.Height = (checkpoint.Speed / 10) * m_workoutInfoView.WorkoutGraphContainer.ActualHeight;
  70:             r.Width = ((double)checkpoint.Time / totalTime) * m_workoutInfoView.WorkoutGraphContainer.ActualWidth;
  71:             r.Style = App.Current.Resources["SpeedGraphRectangle"] as Style;
  72:             r.SetValue(Rectangle.NameProperty, "GraphRect_" + i++);
  73:             speedGraph.Children.Add(r);
  74:             m_graphSegments.Add(checkpoint.Index, r);
  75:         }
  76:  
  77:         // Inclines
  78:         StackPanel inclineGraph = new StackPanel();
  79:         inclineGraph.VerticalAlignment = VerticalAlignment.Bottom;
  80:         inclineGraph.Orientation = Orientation.Horizontal;
  81:         inclineGraph.Margin = new Thickness(5);
  82:         foreach (var checkpoint in m_checkpoints)
  83:         {
  84:             Rectangle r = new Rectangle();
  85:             r.VerticalAlignment = VerticalAlignment.Bottom;
  86:             r.Height = (checkpoint.Incline / 10) * m_workoutInfoView.WorkoutGraphContainer.ActualHeight;
  87:             r.Width = ((double)checkpoint.Time / totalTime) * m_workoutInfoView.WorkoutGraphContainer.ActualWidth;
  88:             r.Style = App.Current.Resources["InclineGraphRectangle"] as Style;
  89:             inclineGraph.Children.Add(r);
  90:         }
  91:  
  92:         Rectangle background = new Rectangle();
  93:         background.Fill = new SolidColorBrush(Colors.Black);
  94:         background.Opacity = 0.3;
  95:         background.Stretch = Stretch.UniformToFill;
  96:  
  97:         graphGrid.Children.Add(background);
  98:         graphGrid.Children.Add(progress);
  99:         graphGrid.Children.Add(speedGraph);
 100:         graphGrid.Children.Add(inclineGraph);
 101:         
 102:  
 103:         if (m_workoutInfoView.WorkoutGraphContainer.Children.Count > 0)
 104:             m_workoutInfoView.WorkoutGraphContainer.Children.RemoveAt(0);
 105:         m_workoutInfoView.WorkoutGraphContainer.Children.Add(graphGrid);
 106:     }
 107:  
 108:     void m_timer_Tick(object sender, EventArgs e)
 109:     {
 110:         TimeSpan ts = DateTime.Now - m_workoutStartTime;
 111:         TimeSpan remaining = GetProgramDuration() - ts;
 112:  
 113:         if (remaining.Seconds < 0)
 114:         {
 115:             StopProgram();
 116:             return;
 117:         }
 118:  
 119:         m_workoutInfoView.WorkoutDuration = ts;
 120:         m_workoutInfoView.WorkoutTimeRemaining = remaining;
 121:  
 122:         progress.Width = (ts.TotalSeconds/GetProgramDuration().TotalSeconds)* m_workoutInfoView.WorkoutGraphContainer.ActualWidth;
 123:  
 124:         int currentCheckpoint = GetCurrentCheckpointIndex();
 125:         if (m_previousCheckpointIndex != currentCheckpoint)
 126:         {
 127:             // Change treadmill settings.
 128:             m_workoutInfoView.UpdateTreadmillSettings(m_checkpoints[currentCheckpoint].Speed, m_checkpoints[currentCheckpoint].Incline);
 129:  
 130:             if (m_previousCheckpointIndex != -1)
 131:             {
 132:                 Rectangle previousRect = m_graphSegments[m_previousCheckpointIndex] as Rectangle;
 133:                 previousRect.Stroke = null;
 134:             }
 135:  
 136:             Rectangle currentRect = m_graphSegments[currentCheckpoint] as Rectangle;
 137:             currentRect.Stroke = new SolidColorBrush(Colors.Yellow);
 138:  
 139:             m_previousCheckpointIndex = currentCheckpoint;
 140:         }
 141:     }
 142:  
 143:     private TimeSpan GetProgramDuration()
 144:     {
 145:         if (!m_workoutLoaded)
 146:         {
 147:             throw new InvalidOperationException("No workout loaded");
 148:         }
 149:  
 150:         int totalTime = 0;
 151:         foreach (var checkpoint in m_checkpoints)
 152:         {
 153:             totalTime += checkpoint.Time;
 154:         }
 155:         return new TimeSpan(0, 0, 0, totalTime);
 156:     }
 157:  
 158:     public int GetCurrentCheckpointIndex()
 159:     {
 160:         int elapsedTime = (int)(DateTime.Now - m_workoutStartTime).TotalSeconds;
 161:         int accTime = 0;
 162:         int i = 0;
 163:         while (elapsedTime >= accTime && i < m_checkpoints.Count)
 164:         {
 165:             accTime += m_checkpoints[i++].Time;
 166:         }
 167:  
 168:         return i - 1;
 169:     }
 170:  
 171:     public void StopProgram()
 172:     {
 173:         if (m_workoutStarted)
 174:         {
 175:             m_workoutStarted = false;
 176:             m_timer.Stop();
 177:             m_workoutInfoView.UpdateTreadmillSettings(0, 0);
 178:         }
 179:     }
 180:  
 181:     public void StartProgram()
 182:     {
 183:         if (m_workoutLoaded)
 184:         {
 185:             m_workoutStarted = true;
 186:             m_timer.Start();
 187:             m_workoutStartTime = DateTime.Now;
 188:         }
 189:     }
 190:  
 191:     public bool WorkoutStarted
 192:     {
 193:         get { return m_workoutStarted; }
 194:     }
 195:  
 196: }

I won't spend any time explaining the TreadmillController class since it is so trivial, but the WorkoutController class warrants some discussion.  You may notice that the constructor of the WorkoutController class takes an IWorkoutInfoView.  We are taking advantage of the Model-View-Controller pattern (MVC) to keep UI logic out of the business layer here.  Our Silverlight codebehind (Page.xaml.cs) will implement this interface and we will inject this into the WorkoutController.  Doing this really makes our codebehind lightweight as almost all of the logic is built into the controllers.  Furthermore, our code is more testable this way since we don't have all of that UI logic to deal with.

The WorkoutController class is not very difficult to understand, it loads a workout xml file and uses a little LINQ to XML to parse the workout into a series of checkpoints.  These checkpoints are stored in a list and are referenced while the graph is being generated and while the workout is executing.

Building the Graph
To build the graph, I simply iterate over the checkpoints and construct rectangle primitives of the appropriate sizes for speed and incline.  The resulting graph is then assigned to the WorkoutGraphContainer declared in IWOrkoutInfoView, and in a polymorphic way, the UI is updated with the graph.  It's like magic!  I also added a progress bar that moves as the workout is executing.  I feel like this gives you a better representation of your workout progress.  This feature did not exist in the video, but you can see it in the screenshot below:

SilverTread

Conclusion
As I mentioned before, this application was just an experiment.  It's of limited use, but it does demonstrate how you can come up with some cool things even when they seem impossible at first glance.  There are a lot of directions that I could take this application.  This is the last post for this series, but I may decide to revisit this sometime in the future.  Below, I've made a list of some features that I would like to add when that time comes:

  • Add more information to the Stats panel.  Include distance traveled, checkpoint time remaining, etc..
  • Allow the user to select a program to load.
  • Provide an interface for creating workouts, allow user to save workouts online.
  • Allow the user to change the speed and incline by dragging the mouse over the graphs (Requested by Andrew Duthie)
  • Add an emergency stop button.
  • Allow the user to skip parts of the exercise by clicking on the graph segment of their choice.
  • Display an actual progress percentage in the graph.
  • Make the incline graph a little more noticeable.
  • Calculate approximate calories burned (I think you need to know weight and height to do this).

Source Code
To download the source code for this post, click on the link below.  I have not included the sounds, because they add a good bit of overhead to the download size and you can generate them yourself.  Also, the code will still run without the sounds.  Enjoy!

Download Source Code

Comments have been closed on this topic.