You should start programming this project by looking at the data structures. As I mentioned, they are a set of collections based on some abstracted database. The collections are homegrown for two reasons:
The collections are strongly typed.
The collections can have certain functionality exposed or not when used as assemblies.
The last reason is why I use homegrown collections the most. Consider that a collection has these five methods in common:
A way to add an object
A way to delete an object
A way to iterate over the collection using a foreach construct
A way to get a single object from the collection
A way to get a count of objects in the collection
The methods are Add, Remove, Item, and Count.
If I made a DLL that exposed some collection of GolfCourses, I would not want to expose a raw collection because the client would be able to call the Add and Remove functions of the collection directly. There would be no way for the DLL to track what is going in and out of the collection.
A better way would be to make your own collection and expose only the Count and Item properties. Adding and removing an object from the collection should be done by methods in the class that holds the collection. This allows you to control what gets added to the collection, and it also allows you to do some other processing that may be needed when something is added to or deleted from the collection. You can limit the exposure of some object's method with the Friend keyword in VB and the Internal keyword in C#.
Listings 11-1a and 11-1b show the code for the IHoleDetails collection. Note that a collection of an item has the suffix s . The object is called IHoleDetail and the collection is called IHoleDetails.
#region Hole Detail collection Classes public class IHoleDetailInfos : IEnumerable { //Slower than Hash table but more flexible //Can get item three ways //Most like VB type collection private SortedList mCol; public IHoleDetailInfos() { mCol = new SortedList(); } #region collection methods // enables foreach processing private mEnum GetEnumerator() { return new mEnum(this); } //Property count public int Count { get { return mCol.Count; } } // ----- add method ------ public void Add(IHoleDetailInfo hole) { mCol.Add(hole.ToString(), hole); mCol.TrimToSize(); } // ----- overloaded remove method ------ public void Remove(int Index) { mCol.RemoveAt(Index); } public void Remove(string key) { mCol.Remove(key); } // ----- overloaded item method ------ public IHoleDetailInfo Item(int index) { return (IHoleDetailInfo) mCol.GetByIndex(index); } public IHoleDetailInfo Item(string key) { return (IHoleDetailInfo) mCol[key]; } #endregion #region enumeration methods // Implement the GetEnumerator() method: IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } // Declare the enumerator and implement the IEnumerator interface: private class mEnum: IEnumerator { private int nIndex; private IHoleDetailInfos collection; // constructor. make the collection public mEnum(IHoleDetailInfos coll) { collection = coll; nIndex = -1; } // start over public void Reset() { nIndex = -1; } // bump up the index public bool MoveNext() { nIndex++; return(nIndex < collection.mCol.Count); } // get the current object // The current property on the IEnumerator interface: object IEnumerator.Current { get { return(collection.mCol.GetByIndex(nIndex)); } } } #endregion } public class IHoleDetailInfo { #region Locals private YardMarker mTeeBox; private int mHole; private int mPar; private GolfClubs mTeeClub; private GolfClubs mSecondClub; private bool mHitfairway; private bool mGood2Shot; private int mShots2Green; private int mPutts; private int mTotalShots; #endregion public IHoleDetailInfo() { mTotalShots = 0; } public override string ToString() { return mHole.ToString(); } #region Properties public YardMarker TeeBox { get{return mTeeBox;} set{mTeeBox = value;} } public GolfClubs ScondClub { get{return mSecondClub;} set{mSecondClub = value;} } public GolfClubs TeeClub { get{return mTeeClub;} set{mTeeClub = value;} } public bool GoodSecondShot { get{return mGood2Shot;} set{mGood2Shot = value;} } public bool HitFairway { get{return mHitfairway;} set{mHitfairway = value;} } public int TotalShots { get{return mTotalShots;} set{mTotalShots = value;} } public int Putts { get{return mPutts;} set{mPutts = value;} } public int ShotsToGreen { get{return mShots2Green;} set{mShots2Green = value;} } public bool GreenInReg { get{return((mPar - mShots2Green >= 2) ? true : false);} } public int Par { get{return mPar;} set{mPar = value;} } public int Hole { get{return mHole;} set{mHole = value;} } #endregion } #endregion
#Region "Hole Detail collection Classes" Public Class IHoleDetailInfos Implements IEnumerable 'Slower than Hash table but more flexible 'Can get item three ways 'Most like VB type collection Private mCol As SortedList Public Sub New() mCol = New SortedList() End Sub ' enables foreach processing Private Function GetMyEnumerator() As mEnum Return New mEnum(Me) End Function ' Implement the GetEnumerator() method: Public Function GetEnumerator() As IEnumerator Implements IEnumerable.GetEnumerator Return GetMyEnumerator() End Function 'Property count Public ReadOnly Property Count() As Int32 Get Return mCol.Count End Get End Property ' ----- add method ------ Public Sub Add(ByVal hole As IHoleDetailInfo) mCol.Add(hole.ToString(), hole) mCol.TrimToSize() End Sub ' ----- overloaded remove method ------ Public Sub Remove(ByVal Index As Int32) mCol.RemoveAt(Index) End Sub Public Sub Remove(ByVal key As String) mCol.Remove(key) End Sub ' ----- overloaded item method ------ Public Function Item(ByVal index As Int32) As IHoleDetailInfo Return CType(mCol.GetByIndex(index), IHoleDetailInfo) End Function Public Function Item(ByVal key As String) As IHoleDetailInfo Return CType(mCol(key), IHoleDetailInfo) End Function ' Declare the enumerator and implement the IEnumerator interface: Private Class mEnum Implements IEnumerator Private nIndex As Int32 Private collection As IHoleDetailInfos ' constructor. make the collection Public Sub New(ByVal coll As IHoleDetailInfos) collection = coll nIndex = -1 End Sub ' start over Public Sub Reset() Implements IEnumerator.Reset nIndex = -1 End Sub ' bump up the index Public Function MoveNext() As Boolean Implements IEnumerator.MoveNext nIndex += 1 Return (nIndex < collection.mCol.Count) End Function ' The current property on the IEnumerator interface: Public ReadOnly Property Current() As Object Implements IEnumerator.Current Get Return collection.mCol.GetByIndex(nIndex) End Get End Property End Class End Class Public Class IHoleDetailInfo #Region "Locals" Private mTeeBox As YardMarker Private mHole As Int32 Private mPar As Int32 Private mTeeClub As GolfClubs Private mSecondClub As GolfClubs Private mHitfairway As Boolean Private mGood2Shot As Boolean Private mShots2Green As Int32 Private mPutts As Int32 Private mTotalShots As Int32 #End Region Public Sub New() mTotalShots = 0 End Sub Public Overrides Function ToString() As String Return mHole.ToString() End Function #Region "Properties" Public ReadOnly Property GreenInReg() As Boolean Get Return (IIf(mPar - mShots2Green >= 2, True, False)) End Get End Property Public Property TeeBox() As YardMarker Get Return mTeeBox End Get Set(ByVal Value As YardMarker) mTeeBox = Value End Set End Property Public Property ScondClub() As GolfClubs Get Return mSecondClub End Get Set(ByVal Value As GolfClubs) mSecondClub = Value End Set End Property Public Property TeeClub() As GolfClubs Get Return mTeeClub End Get Set(ByVal Value As GolfClubs) mTeeClub = Value End Set End Property Public Property GoodSecondShot() As Boolean Get Return mGood2Shot End Get Set(ByVal Value As Boolean) mGood2Shot = Value End Set End Property Public Property HitFairway() As Boolean Get Return mHitfairway End Get Set(ByVal Value As Boolean) mHitfairway = Value End Set End Property Public Property TotalShots() As Int32 Get Return mTotalShots End Get Set(ByVal Value As Int32) mTotalShots = Value End Set End Property Public Property Putts() As Int32 Get Return mPutts End Get Set(ByVal Value As Int32) mPutts = Value End Set End Property Public Property ShotsToGreen() As Int32 Get Return mShots2Green End Get Set(ByVal Value As Int32) mShots2Green = Value End Set End Property Public Property Par() As Int32 Get Return mPar End Get Set(ByVal Value As Int32) mPar = Value End Set End Property Public Property Hole() As Int32 Get Return mHole End Get Set(ByVal Value As Int32) mHole = Value End Set End Property #End Region End Class
I have three such sets of classes: one for the individual holes, one for the scorecard, and one for the golf course itself. Figure 11-7 shows the graphical view of the data structure.
What you have here is a collection of GolfCourse objects. Each of these GolfCourse objects has a collection of ScoreCard objects. Each of these ScoreCard objects has a collection of HoleDetail objects. Collections of collections of collections.
There is nothing too exciting about the IHoleDetail object except for one thing: I override the ToString() method. You have seen me do this before. I do it so that I can add an IHoleDetail object to a ComboBox list. The ComboBox always uses the ToString() method as the text to show in the control.
The collection is a bit more exciting. Notice that the IHoleDetails collection implements the IEnumerable interface.[4] I use a SortedList as the base collection for all the homegrown collections in this project. I do this because you can get and remove objects via an index or a key. It has the added benefit of keeping the list sorted, which can come in handy. The methods I have in here are as follows:
Count
Add
Remove
Item
These are the classic collection methods.
The IEnumerable interface gives me the ability to enumerate over the collection using the foreach construct. This code implements the following functions:
Reset: Enumeration is done in a forward-only fashion. This function starts at the beginning.
MoveNext: This function bumps up the index into the collection. It also makes sure you cannot go past the end of the collection.
Current: This function returns the current object in the collection as defined by the index.
Here is the code for the IScoreCardInfo object.
C#
public class IScoreCardInfo { public IHoleDetailInfos holes; private DateTime mDate; public IScoreCardInfo() { holes = new IHoleDetailInfos(); mDate = DateTime.Now; } public IScoreCardInfo(int numHoles) { holes = new IHoleDetailInfos(); IHoleDetailInfo h; for(int k=0; k<numHoles; k++) { h = new IHoleDetailInfo(); h.TotalShots = 0; h.Hole = k+1; h.Putts = 0; holes.Add(h); } mDate = DateTime.Now; } public DateTime PlayDate { get{return mDate;} set{mDate = value;} } public int RoundScore { get { int score = 0; foreach(IHoleDetailInfo h in holes) score += h.TotalShots; return score; } } }
Public Class IScoreCardInfo Public holes As IHoleDetailInfos Private mDate As DateTime Public Sub New() holes = New IHoleDetailInfos() mDate = DateTime.Now End Sub Public Sub New(ByVal numHoles As Int32) holes = New IHoleDetailInfos() Dim h As IHoleDetailInfo Dim k As Int32 For k = 0 To numHoles - 1 h = New IHoleDetailInfo() h.TotalShots = 0 h.Hole = k + 1 h.Putts = 0 holes.Add(h) Next mDate = DateTime.Now End Sub Public Property PlayDate() As DateTime Get Return mDate End Get Set(ByVal Value As DateTime) mDate = Value End Set End Property Public ReadOnly Property RoundScore() As Int32 Get Dim score As Int32 = 0 Dim h As IHoleDetailInfo For Each h In holes score += h.TotalShots Next Return score End Get End Property End Class
You can see that there is a public collection of IHoleDetailInfo objects. Each scorecard has its own collection. The constructor is responsible for making this collection and initializing some variables.
Notice also that the RoundScore property of this object gets the score in real time. It enumerates over its collection of holes and totals the individual scores. If I did not implement the IEnumerable interface on the IHoleDetails object, I would not be able to do this.
The final collection here is the GolfCourse collection. This contains a list of tee distances and a collection of scorecards. I refer you to the book's code (which you can download from the Downloads section of the Apress Web site at http://www.apress.com) to see this.
The rest of this CollectionClass code contains a static Globals class and some enums. The Globals class is an interesting way to have global variables within a program. It is even thread-safe because I only read variables from it.
I have a class that acts as a buffer between the program and the database. As I mentioned, I do not keep a database for this project, but the code in this class demonstrates how to work with whatever database you do use. This database class shows how to construct the golf course from the holes to the scorecards on up.
Listings 11-2a and 11-2b show the complete database class.
using System; namespace Golf_c { /// <summary> /// This is the database abstraction code. /// Note that the rest of the program knows nothing /// about the database or even that there is one. /// The only thing it knows about is the GolfCourse collection /// </summary> public class Database { #region locals public ICourseInfos GolfCourses; #endregion public Database() { GolfCourses = new ICourseInfos(); //You could either put some code here to get the complete database at the //start or make a method that needs to be called explicitly to //do it. This database, even after years of playing, would be very small. //Caching the whole thing in memory is not a big deal. //This simulates getting the data from a database. GetGolfCourses(); } public void SaveCourse(ICourseInfo GolfCourse) { if(GolfCourses.Item(GolfCourse.Name) == null) GolfCourses.Add(GolfCourse); //Put some code in here to save to a database. } private void GetGolfCourses() { //Go out to the database and get the golf course info here ICourseInfo course = new ICourseInfo(); course.NumberOfHoles = 9; course.Name = "My Back Yard"; course.Par = CoursePar._35; course.Slope = 127; Tees tee; for(int k=0; k<course.Hole.Count; k++) { tee = (Tees)course.Hole[k]; tee.BlueDistance = 450; tee.WhiteDistance = 430; tee.RedDistance = 400; tee.Par = k<5 ? 4 : 5; tee.HoleNumber = k+1; } IScoreCardInfo card = new IScoreCardInfo(); card.PlayDate = DateTime.Now; IHoleDetailInfo detail; for(int k=0; k<course.NumberOfHoles; k++) { detail = new IHoleDetailInfo(); detail.Hole = k+1; detail.TeeClub = GolfClubs.Driver; detail.HitFairway = true; detail.ScondClub = GolfClubs.Nine_wood; detail.GoodSecondShot = true; detail.ShotsToGreen = 2; detail.Putts = 2; detail.TotalShots = 4; detail.Par = ((Tees)course.Hole[k]).Par; detail.TeeBox = YardMarker.White; card.holes.Add(detail); } course.ScoreCards.Add(card); GolfCourses.Add(course); } } }
Option Strict On ' This is the database abstraction code. ' Note that the rest of the program knows nothing ' about the database or even that there is one. ' The only thing it knows about is the GolfCourse collection ' This database abstraction includes some multithreaded code. Public Class Database #Region "locals" Public GolfCourses As ICourseInfos #End Region Public Sub New() GolfCourses = New ICourseInfos() 'You could either put some code here to get the complete database at the 'start or make a method that needs to be called explicitly to 'do it. This database, even after years of playing, would be very small. 'Caching the whole thing in memory is not a big deal. 'This simulates getting the data from a database. GetGolfCourses() End Sub Public Sub SaveCourse(ByVal GolfCourse As ICourseInfo) If GolfCourses.Item(GolfCourse.Name) Is Nothing Then GolfCourses.Add(GolfCourse) End If 'Put some code in here to save to a database. End Sub Private Sub GetGolfCourses() 'Go out to the database and get the golf course info here Dim course As ICourseInfo = New ICourseInfo() course.NumberOfHoles = 9 course.Name = "My Back Yard" course.Par = CoursePar._35 course.Slope = 127 Dim tee As Tees Dim k As Int32 For k = 0 To course.Hole.Count - 1 tee = CType(course.Hole(k), Tees) tee.BlueDistance = 450 tee.WhiteDistance = 430 tee.RedDistance = 400 If k < 5 Then tee.Par = 4 Else tee.Par = 5 End If tee.HoleNumber = k + 1 Next Dim card As IScoreCardInfo = New IScoreCardInfo() card.PlayDate = DateTime.Now Dim detail As IHoleDetailInfo For k = 0 To course.NumberOfHoles - 1 detail = New IHoleDetailInfo() detail.Hole = k + 1 detail.TeeClub = GolfClubs.Driver detail.HitFairway = True detail.ScondClub = GolfClubs.Nine_wood detail.GoodSecondShot = True detail.ShotsToGreen = 2 detail.Putts = 2 detail.TotalShots = 4 detail.Par = (CType(course.Hole(k), Tees)).Par detail.TeeBox = YardMarker.White card.holes.Add(detail) Next course.ScoreCards.Add(card) GolfCourses.Add(course) End Sub End Class
What I have here is a class local variable that holds the collection of golf courses associated with this database. When the constructor is called, I go and create a single IGolfCourseInfo object on the fly that has just one IScoreCardInfo object.
You can see from this code how easy it is to work with homegrown collections. Try adding an object to the GolfCourses collection that is not an IGolfCourseInfo object. You will get a compiler error. You never even get to run the code.
If you had a simple collection such as an ArrayList, you would be able to put any object in it. This would cause problems down the line. Catching errors at compile time is the way to go if you can.
The initial screen for this program is the MDI parent screen, of course. Let's look at the code for that.
Basically, what I have in this class is a class local variable called "db" that is an instantiated database object. Now when I bring up some of these other child forms, such as the course form or the statistics form, I need access to this database object. After all, it is not only the interface to the actual database, but also it is the keeper of the GolfCourses collection.
Although I have quite a few items in the menu, only a few are wired up to anything. They are the menu choices to open up a course for editing and to view statistics. Here is the code to instantiate these two child forms.
C#
private void EditCourse(object sender, EventArgs e) { Course frm = new Course(); frm.MdiParent = this; frm.Show(); } private void Stats(object sender, EventArgs e) { Statistics frm = new Statistics(); frm.MdiParent = this; frm.Show(); }
VB
Private Sub EditCourse(ByVal sender As Object, ByVal e As EventArgs) Dim frm As Course = New Course() frm.MdiParent = Me frm.Show() End Sub Private Sub Stats(ByVal sender As Object, ByVal e As EventArgs) Dim frm As Statistics = New Statistics() frm.MdiParent = Me frm.Show() End Sub
All I do is make a new object of the child form I want and set its MdiParent property to the current form. Setting the MdiParent property does two things. First, it tells .NET that this is a child form and that it should reside in the parent's container. Second, it gives the child form a mechanism to call back into the parent form whenever it needs to.
Calling back into the parent form is how the child forms access the database object. It is also a standard way for child forms to communicate with each other. In this case, the parent form acts as an intermediary for child-to-child communication.
The first child form you will look at is the courses form. This form has a tab control that separates editing of the course itself and the course scores. Let's look at the first tab again, as shown in Figure 11-8.
The first thing you may notice is that I swapped the location of the tabs via code. The design-time version of this form has the tabs on the top. For a program like this, I prefer the Excel tab locations.
This form is currently in edit mode. As soon as I click the Edit button, I disable it and I also disable the ability to create a new course. This prevents the user from doing anything he or she should not do. It also prevents me from having to write tons of code that checks if a certain operation is allowed if I am in edit mode. I just do not allow it.
While in edit mode, I enable the Holes, Par, and Slope fields, as well as the Save button. Now, I could have made the Holes and the Par fields TextBoxes and left it at that. If I did that, though, I would need to write a fair bit of validation code to make sure that the choices matched a certain range. I would have the added problem that the range of Par values differs if you have 9 or 18 holes.
The best thing to do is what I do here. I use the CoursePar enumeration from the Collections class to fill in the Par ComboBox. This enum contains all the common possibilities for either 9 or 18 holes of golf. If the user chooses 9 holes, then I clear the Par ComboBox and refill it with the appropriate values.
Listings 11-3a and 11-3b show the initialization code for the Course Setup tab and the delegate that handles the Holes ComboBox click event.
private void GolfCourseTabSetup() { lblLength.BackColor = Color.LightGray; //Slope must be between 55 and 155 txtSlope.MaxLength = Globals.SlopeLen; txtSlope.KeyPress += new KeyPressEventHandler(this.SlopeKeyPress); txtSlope.Validating += new CancelEventHandler(this.SlopeValidate); cmdNew.BackColor = Color.SandyBrown; cmdNew.Font = cmdQuit.Font; cmdNew.Image = Image.FromFile("new.ico"); cmdNew.Click += new EventHandler(this.NewCourse); cmdSave.BackColor = Color.SandyBrown; cmdSave.Font = cmdQuit.Font; cmdSave.Click += new EventHandler(this.SaveCourse); cmdSave.Enabled = false; cmdEdit.BackColor = Color.SandyBrown; cmdEdit.Font = cmdQuit.Font; cmdEdit.Click += new EventHandler(this.EditCourse); cmbPar.DropDownStyle = ComboBoxStyle.DropDownList; cmbHoles.DropDownStyle = ComboBoxStyle.DropDownList; cmbHoles.Items.Add(Globals.NineHoles); cmbHoles.Items.Add(Globals.EighteenHoles); cmbHoles.SelectedIndexChanged += new EventHandler(this.SelectPar); cmbHoles.SelectedIndex = 1; cmbCourseName.MaxLength = 60; cmbCourseName.DropDownStyle = ComboBoxStyle.DropDownList; cmbCourseName.SelectedIndexChanged += new EventHandler(ChangeCourse); foreach(ICourseInfo c in mParent.db.GolfCourses) { cmbCourseName.Items.Add(c); } if(cmbCourseName.Items.Count>0) cmbCourseName.SelectedIndex = 0; lstTees.MouseUp += new MouseEventHandler(EditTeeBox); } private void SelectPar(object sender, EventArgs e) { Debug.Assert(sender == cmbHoles, "SelectPar method called by wrong control"); cmbPar.BeginUpdate(); cmbPar.Items.Clear(); if((int)cmbHoles.SelectedItem == Globals.NineHoles) { cmbPar.Items.Add((int)CoursePar._36); cmbPar.Items.Add((int)CoursePar._35); cmbPar.Items.Add((int)CoursePar._27); } else { cmbPar.Items.Add((int)CoursePar._72); cmbPar.Items.Add((int)CoursePar._71); cmbPar.Items.Add((int)CoursePar._70); cmbPar.Items.Add((int)CoursePar._54); } cmbPar.SelectedIndex = 0; cmbPar.EndUpdate(); //While I am in here I need to create the listview on the fly //The listview depends upon the number of holes if(ThisCourse != null) Debug.WriteLine(ThisCourse.Name); if(ThisCourse != null && !cmdEdit.Enabled) ThisCourse.NumberOfHoles = (int)cmbHoles.SelectedItem; SetupTeeList(); }
Private Sub GolfCourseTabSetup() lblLength.BackColor = Color.LightGray 'Slope must be between 55 and 155 txtSlope.MaxLength = Globals.SlopeLen AddHandler txtSlope.KeyPress, New KeyPressEventHandler(AddressOf SlopeKeyPress) AddHandler txtSlope.Validating, New CancelEventHandler(AddressOf SlopeValidate) cmdNew.BackColor = Color.SandyBrown cmdNew.Font = cmdQuit.Font cmdNew.Image = Image.FromFile("new.ico") AddHandler cmdNew.Click, New EventHandler(AddressOf NewCourse) cmdSave.BackColor = Color.SandyBrown cmdSave.Font = cmdQuit.Font AddHandler cmdSave.Click, New EventHandler(AddressOf SaveCourse) cmdSave.Enabled = False cmdEdit.BackColor = Color.SandyBrown cmdEdit.Font = cmdQuit.Font AddHandler cmdEdit.Click, New EventHandler(AddressOf EditCourse) cmbPar.DropDownStyle = ComboBoxStyle.DropDownList cmbHoles.DropDownStyle = ComboBoxStyle.DropDownList cmbHoles.Items.Add(Globals.NineHoles) cmbHoles.Items.Add(Globals.EighteenHoles) AddHandler cmbHoles.SelectedIndexChanged, _ New EventHandler(AddressOf SelectPar) cmbHoles.SelectedIndex = 1 cmbCourseName.MaxLength = 60 cmbCourseName.DropDownStyle = ComboBoxStyle.DropDownList AddHandler cmbCourseName.SelectedIndexChanged, _ New EventHandler(AddressOf ChangeCourse) Dim c As ICourseInfo For Each c In mParent.db.GolfCourses cmbCourseName.Items.Add(c) Next If cmbCourseName.Items.Count > 0 Then cmbCourseName.SelectedIndex = 0 End If AddHandler lstTees.MouseUp, New MouseEventHandler(AddressOf EditTeeBox) Private Sub SelectPar(ByVal sender As Object, ByVal e As EventArgs) Debug.Assert(sender Is cmbHoles, _ "SelectPar method called by wrong control") cmbPar.BeginUpdate() cmbPar.Items.Clear() If CType(cmbHoles.SelectedItem, Int32) = Globals.NineHoles Then cmbPar.Items.Add(CType(CoursePar._36, Int32)) cmbPar.Items.Add(CType(CoursePar._35, Int32)) cmbPar.Items.Add(CType(CoursePar._27, Int32)) Else cmbPar.Items.Add((CType(CoursePar._72, Int32))) cmbPar.Items.Add(CType(CoursePar._71, Int32)) cmbPar.Items.Add(CType(CoursePar._70, Int32)) cmbPar.Items.Add(CType(CoursePar._54, Int32)) End If cmbPar.SelectedIndex = 0 cmbPar.EndUpdate() 'While I am in here I need to create the listview on the fly 'The listview depends upon the number of holes If Not ThisCourse Is Nothing And Not cmdEdit.Enabled Then ThisCourse.NumberOfHoles = CType(cmbHoles.SelectedItem, Int32) End If SetupTeeList() End Sub
The setup routine makes sure that I have a validation and KeyPress handler for the Slope field. It also goes out to the static Globals class and gets the maximum length that the field can be. Doing this allows me to change the Slope parameters in the Globals class, and all other code that uses these parameters will change automatically.
You may be wondering why I did not do any setup of the cmbPar ComboBox in the setup routine, yet when the program runs this field is set up correctly. Note the code that sets up the cmbHoles ComboBox:
cmbHoles.DropDownStyle = ComboBoxStyle.DropDownList; cmbHoles.Items.Add(Globals.NineHoles); cmbHoles.Items.Add(Globals.EighteenHoles); cmbHoles.SelectedIndexChanged += new EventHandler(this.SelectPar); cmbHoles.SelectedIndex = 1;
I wire up the delegate and then set the selected item to a value. Setting the SelectedIndex value automatically fires the SelectedIndexChanged event. I rely on the delegate to set up the cmbPar ComboBox correctly. If I had reversed these last two lines of code, the program would not work correctly.
I override the ToString method in several classes. The ICourseInfo class is one of them. Note this code:
foreach(ICourseInfo c in mParent.db.GolfCourses) { cmbCourseName.Items.Add(c); }
I am adding the ICourseInfo object directly to the cmbCourseName ComboBox. The ComboBox uses the ToString method of the object to display information about the object. You saw me demonstrate this in previous chapters.
The delegate shown here is no big surprise. Note, though, that I use the Debug.Assert function to make sure that this delegate is not being called by something that should not call it. This assertion code will get compiled out of the release code.
Here is the code that handles the Slope field.
C#
private void SlopeKeyPress(object sender, KeyPressEventArgs e) { Debug.Assert(sender == txtSlope, "SlopeKeyPress method called by wrong control"); if(e.KeyChar < '0' || e.KeyChar > '9') e.Handled = true; //0 cannot be leading digit if(txtSlope.Text == "" && e.KeyChar == '0') e.Handled = true; } private void SlopeValidate(object sender, CancelEventArgs e) { Debug.Assert(sender == txtSlope, "SlopeValidate method called by wrong control"); try { int slope = int.Parse(txtSlope.Text); if(slope < Globals.MinSlope || slope > Globals.MaxSlope) { err.SetError(txtSlope, "Slope must be between 55 and 155"); e.Cancel = true; } } catch { err.SetError(txtSlope, "BUG: Slope must be an integer"); e.Cancel = true; } if(e.Cancel) txtSlope.SelectAll(); else err.SetError(txtSlope, ""); }
VB
Private Sub SlopeKeyPress(ByVal sender As Object, ByVal e As KeyPressEventArgs) Debug.Assert(sender Is txtSlope, _ "SlopeKeyPress method called by wrong control") If e.KeyChar < "0" Or e.KeyChar > "9" Then e.Handled = True End If '0 cannot be leading digit If txtSlope.Text = "" AndAlso e.KeyChar = "0" Then e.Handled = True End If End Sub Private Sub SlopeValidate(ByVal sender As Object, ByVal e As CancelEventArgs) Debug.Assert(sender Is txtSlope, _ "SlopeValidate method called by wrong control") Try Dim slope As Int32 = Int32.Parse(txtSlope.Text) If slope < Globals.MinSlope Or slope > Globals.MaxSlope Then err.SetError(txtSlope, "Slope must be between 55 and 155") e.Cancel = True End If Catch err.SetError(txtSlope, "BUG: Slope must be an integer") e.Cancel = True End Try If (e.Cancel) Then txtSlope.SelectAll() Else err.SetError(txtSlope, "") End If End Sub
You can see here that I use the ErrorProvider control to note any problems with the validation of this field. I use this error control within the confines of a Try-Catch block. In previous chapters I used Catch blocks to show message boxes. As you see here, the Catch block is also a good place to put an ErrorProvider control.
By the way, do you know what error I am catching here? Yup, the integer parse. I could have tested for the TextBox value to be an integer before trying to parse. This is an alternate way.
The next tab contains quite a bit of setup and event handling. Figure 11-9 shows the Course scores tab.
As you can see, I started with an original scorecard and then clicked the New Card button several times. Each time I clicked this button I added a new IScoreCardInfo object to the ScoreCard collection within the current course.
Notice that there is no new record indicator on the DataGrid. I prevent the user from generating a new record by using the DataGrid. I want complete control over what is happening.
Listings 11-4a and 11-4b show the code that gets run when the user switches from the Course Setup tab to the Course scores tab.
private void AddScoreCardStyle() { //First clear the existing one out dg1.TableStyles.Clear(); DataGridTableStyle ts1 = new DataGridTableStyle(); ts1.MappingName = "Course Score"; // Set other properties. ts1.AlternatingBackColor = Color.LightGray; // // Add textbox column style so we can catch textbox mouse clicks DataGridTextBoxColumn TextCol = new DataGridTextBoxColumn(); TextCol.MappingName = "Date"; TextCol.HeaderText = "Date"; TextCol.Width = 100; TextCol.TextBox.Validating += new CancelEventHandler(DateCellValidating); TextCol.TextBox.DoubleClick += new EventHandler(CellDateClick); TextCol.TextBox.KeyPress += new KeyPressEventHandler(CellDateKeyPress); ts1.GridColumnStyles.Add(TextCol); TextCol = new DataGridTextBoxColumn(); TextCol.MappingName = "Score"; TextCol.HeaderText = "Score"; TextCol.Width = 50; TextCol.TextBox.Enabled = false; ts1.GridColumnStyles.Add(TextCol); for(int k=1; k<ThisCourse.NumberOfHoles+1; k++) { TextCol = new DataGridTextBoxColumn(); TextCol.MappingName = k.ToString(); TextCol.HeaderText = k.ToString(); TextCol.Width = 50; TextCol.TextBox.MaxLength = 2; TextCol.TextBox.DoubleClick += new EventHandler(HoleScoreDblClick); TextCol.TextBox.KeyPress += new KeyPressEventHandler(HoleScoreEntry); TextCol.TextBox.Validating += new CancelEventHandler(HoleScoreValidate); ts1.GridColumnStyles.Add(TextCol); } dg1.TableStyles.Add(ts1); } private void SetupScoreCardDatagrid() { //This must be set up based upon the scorecard collection within //the thiscourse object. As each cell is clicked it brings up a hole //detail. If the user chooses not to click a cell he can in-place edit //just the total score. Either way anytime a cell is edited I will need //to change something in the iholedetail object that belongs to this hole. Debug.Assert(ThisCourse != null, "Must have a valid course"); //Generate a column style collection that makes each cell a text box. AddScoreCardStyle(); DataColumn dc; //Set the datasource to null at start. dg1.DataSource = null; DataSet DS = new DataSet(); //Top-level table DataTable DT = new DataTable("Course Score"); dc = new DataColumn("Date", System.Type.GetType("System.DateTime")); DT.Columns.Add(dc); dc = new DataColumn("Score", System.Type.GetType("System.Int32")); DT.Columns.Add(dc); for(int k=1; k<ThisCourse.NumberOfHoles+1; k++) { dc = new DataColumn(k.ToString(), System.Type.GetType("System.Int32")); DT.Columns.Add(dc); } //<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> //Add something here to catch if the number of holedetail objects exceeds //the number of holes in the course. If so then write to an error file. //<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> ArrayList scores = new ArrayList(); foreach(IScoreCardInfo s in ThisCourse.ScoreCards) { scores.Clear(); scores.Add(s.PlayDate.ToShortDateString()); scores.Add(s.RoundScore); foreach(IHoleDetailInfo h in s.holes) { //Remember that the basis for this collection is a SortedList so it //should come out in order. scores.Add(h.TotalShots); } DT.Rows.Add(scores.ToArray()); } DS.Tables.Add(DT); dg1.CaptionText = cmbCourseName.Text + " / Date of play and Hole score "; dg1.CaptionFont = new Font("Comic Sans MS", 10); dg1.Font = new Font("Arial", 8); dg1.DataSource = DS; dg1.DataMember = "Course Score"; dg1.CurrentCellChanged += new EventHandler(this.CellChanged); //Remember binding the property of one control to the property of another. //This was managed by a PropertyManager object. When you have an //object that derives from the IList interface such as a collection, //then each of these objects has a CurrencyManager. I am changing the //data view object that belongs to this data source's datamember //(The table name in this case). I am making sure that the user cannot //add a new row using this dataview. See the online help //"Consumers of Data on Windows Forms" for more explanation. CurrencyManager cm = (CurrencyManager)this.BindingContext[dg1.DataSource, dg1.DataMember]; ((DataView)cm.List).AllowNew = false; cmdNewCard.BackColor = Color.SandyBrown; cmdNewCard.Click += new EventHandler(NewCard); }
Private Sub AddScoreCardStyle() 'First clear the existing one out dg1.TableStyles.Clear() Dim ts1 As DataGridTableStyle = New DataGridTableStyle() ts1.MappingName = "Course Score" ' Set other properties. ts1.AlternatingBackColor = Color.LightGray ' ' Add textbox column style so we can catch textbox mouse clicks Dim TextCol As DataGridTextBoxColumn = New DataGridTextBoxColumn() TextCol.MappingName = "Date" TextCol.HeaderText = "Date" TextCol.Width = 100 AddHandler TextCol.TextBox.Validating, _ New CancelEventHandler(AddressOf DateCellValidating) AddHandler TextCol.TextBox.DoubleClick, _ New EventHandler(AddressOf CellDateClick) AddHandler TextCol.TextBox.KeyPress, _ New KeyPressEventHandler(AddressOf CellDateKeyPress) ts1.GridColumnStyles.Add(TextCol) TextCol = New DataGridTextBoxColumn() TextCol.MappingName = "Score" TextCol.HeaderText = "Score" TextCol.Width = 50 TextCol.TextBox.Enabled = False ts1.GridColumnStyles.Add(TextCol) Dim k As Int32 For k = 1 To ThisCourse.NumberOfHoles + 1 TextCol = New DataGridTextBoxColumn() TextCol.MappingName = k.ToString() TextCol.HeaderText = k.ToString() TextCol.Width = 50 TextCol.TextBox.MaxLength = 2 AddHandler TextCol.TextBox.DoubleClick, _ New EventHandler(AddressOf HoleScoreDblClick) AddHandler TextCol.TextBox.KeyPress, _ New KeyPressEventHandler(AddressOf HoleScoreEntry) AddHandler TextCol.TextBox.Validating, _ New CancelEventHandler(AddressOf HoleScoreValidate) ts1.GridColumnStyles.Add(TextCol) Next dg1.TableStyles.Add(ts1) End Sub Private Sub SetupScoreCardDatagrid() 'This must be set up based upon the scorecard collection within 'the thiscourse object. as each cell is clicked it brings up a hole 'detail. If the user chooses not to click a cell he can in-place edit 'just the total score. Either way anytime a cell is edited I will need 'to change something in the iholedetail object that belongs to this hole. Debug.Assert(Not ThisCourse Is Nothing, "Must have a valid course") 'Generate a column style collection that makes each cell a text box. AddScoreCardStyle() Dim dc As DataColumn 'Set the datasource to null at start. dg1.DataSource = Nothing Dim DS As DataSet = New DataSet() 'Top-level table Dim DT As DataTable = New DataTable("Course Score") dc = New DataColumn("Date", System.Type.GetType("System.DateTime")) DT.Columns.Add(dc) dc = New DataColumn("Score", System.Type.GetType("System.Int32")) DT.Columns.Add(dc) Dim k As Int32 For k = 1 To ThisCourse.NumberOfHoles dc = New DataColumn(k.ToString(), System.Type.GetType("System.Int32")) DT.Columns.Add(dc) Next '<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 'Add something here to catch if the number of holedetail objects exceeds 'the number of holes in the course. If so then write to an error file. '<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> Dim scores As ArrayList = New ArrayList() Dim s As IScoreCardInfo For Each s In ThisCourse.ScoreCards scores.Clear() scores.Add(s.PlayDate.ToShortDateString()) scores.Add(s.RoundScore) Dim h As IHoleDetailInfo For Each h In s.holes 'Remember that the basis for this collection is a SortedList so it 'should come out in order. scores.Add(h.TotalShots) Next DT.Rows.Add(scores.ToArray()) Next DS.Tables.Add(DT) dg1.CaptionText = cmbCourseName.Text + " / Date of play and Hole score " dg1.CaptionFont = New Font("Comic Sans MS", 10) dg1.Font = New Font("Arial", 8) dg1.DataSource = DS dg1.DataMember = "Course Score" AddHandler dg1.CurrentCellChanged, New EventHandler(AddressOf CellChanged) 'Remember binding the property of one control to the property of another. 'This was managed by a PropertyManager object. When you have an 'object that derives from the IList interface such as a collection, 'then each of these objects has a CurrencyManager. I am changing the 'data view object that belongs to this data source's datamember '(The table name in this case). I am making sure that the user cannot 'add a new row using this dataview. See the online help '"Consumers of Data on Windows Forms" for more explanation. Dim cm As CurrencyManager = CType(Me.BindingContext(dg1.DataSource, _ dg1.DataMember), _ CurrencyManager) Dim dv As DataView = CType(cm.List, DataView) dv.AllowNew = False cmdNewCard.BackColor = Color.SandyBrown AddHandler cmdNewCard.Click, New EventHandler(AddressOf NewCard) End Sub
The first function here is designed to set up a certain view of the DataGrid. I set it up so that each cell is a TextBox. Once I have done that, I can attach delegates to the TextBox events. This is how I am able to get the HoleDetail screen to pop up when the user double-clicks a cell.
Consider these two lines of code from the SetupScoreCardDatagrid method:
CurrencyManager cm = (CurrencyManager)this.BindingContext[dg1.DataSource, dg1.DataMember]; ((DataView)cm.List).AllowNew = false;
The CurrencyManager is the object that handles a data view of a DataGrid. In this case, I am telling the data view for this DataGrid to disallow automatic addition of new rows. Pretty cool, isn't it?
The AddCardStyle method is responsible for setting up the DataGrid so that it shows the correct number of cells according to how many holes the course has. This makes the grids dynamic and presents a nicer interface than if I just clamped everything to 18 holes. It also sets up the cell style and wires up some delegates to catch TextBox events. Notice that I do more than just catch the double-click event. I also catch the Validating and KeyPress events. I do this so that the user can just enter the hole's score by typing directly into the cell. I give the user the option of detailing the hole statistics or just recording the score.
I find that giving users some choice in what data is entered and how it is entered gives the impression that you put some thought into the program. Many times I have seen programs that are aimed at computer novices only. This may be OK for first-time users, but as people get familiar with your program, they will start searching for ways to speed up data entry. Adding alternate ways to input data can make a big difference.
Note that in this setup I disable the TextBox for the Total Score. Once the user clicks in this cell, the TextBox associated with this cell is activated and the cell goes gray, which indicates that the user cannot enter data in it.
Let's take a quick look at some of the data validation routines for the DataGrid cells.
private void CellDateClick(object sender, EventArgs e) { //Handle double-clicking on the date field //If you want you can bring up a calendar dialog here to make it //easy for the user to pick a date } private void CellDateKeyPress(object sender, KeyPressEventArgs e) { //Handle entering data in the date field if(!Regex.IsMatch(e.KeyChar.ToString(), "[0-9/-]")) e.Handled = true; } private void DateCellValidating(object sender, CancelEventArgs e) { Debug.Assert(sender is TextBox, "Sender must be a datagrid cell that is a textbox"); string DateMatch = "[0-1]?[0-9]/[0-3]?[0-9]/[0-9]{4}$"; if(Regex.IsMatch(((TextBox)sender).Text, DateMatch)) if(ThisCard != null) ThisCard.PlayDate = DateTime.Parse(((TextBox)sender).Text); else e.Cancel = true; } private void HoleScoreEntry(object sender, KeyPressEventArgs e) { if(!Char.IsDigit(e.KeyChar)) e.Handled = true; } private void HoleScoreValidate(object sender, CancelEventArgs e) { Debug.Assert(sender is TextBox, "Sender must be a datagrid cell that is a textbox"); if(ThisCard != null && ThisHole != null) { ThisHole.TotalShots = int.Parse(((TextBox)sender).Text); dg1[dg1.CurrentCell.RowNumber, 1] = ThisCard.RoundScore; } } private void CellChanged(object sender, EventArgs e) { try { ThisCard = ThisCourse.ScoreCards.Item(dg1.CurrentCell.RowNumber); ThisHole = ThisCard.holes.Item(dg1.CurrentCell.ColumnNumber-2); } catch { ThisCard = null; ThisHole = null; } }
VB
Private Sub CellDateClick(ByVal sender As Object, ByVal e As EventArgs) 'Handle double-clicking on the date field 'If you want you can bring up a calendar dialog here to make it 'easy for the user to pick a date End Sub Private Sub CellDateKeyPress(ByVal sender As Object, _ ByVal e As KeyPressEventArgs) 'Handle entering data in the date field If Not Regex.IsMatch(e.KeyChar.ToString(), "[0-9/-]") Then e.Handled = True End If End Sub Private Sub DateCellValidating(ByVal sender As Object, _ ByVal e As CancelEventArgs) Debug.Assert(TypeOf (sender) Is TextBox, _ "Sender must be a datagrid cell that is a textbox") Dim DateMatch As String = "[0-1]?[0-9]/[0-3]?[0-9]/[0-9]{4}$" If Regex.IsMatch((CType(sender, TextBox)).Text, DateMatch) Then If Not ThisCard Is Nothing Then ThisCard.PlayDate = DateTime.Parse((CType(sender, TextBox)).Text) Else e.Cancel = True End If End If End Sub Private Sub HoleScoreEntry(ByVal sender As Object, _ ByVal e As KeyPressEventArgs) If Not Char.IsDigit(e.KeyChar) Then e.Handled = True End If End Sub Private Sub HoleScoreValidate(ByVal sender As Object, _ ByVal e As CancelEventArgs) Debug.Assert(TypeOf (sender) Is TextBox, _ "Sender must be a datagrid cell that is a textbox") If Not ThisCard Is Nothing AndAlso Not ThisHole Is Nothing Then ThisHole.TotalShots = Int32.Parse((CType(sender, TextBox).Text)) dg1(dg1.CurrentCell.RowNumber, 1) = ThisCard.RoundScore End If End Sub Private Sub CellChanged(ByVal sender As Object, ByVal e As EventArgs) Try ThisCard = ThisCourse.ScoreCards.Item(dg1.CurrentCell.RowNumber) ThisHole = ThisCard.holes.Item(dg1.CurrentCell.ColumnNumber - 2) Catch ThisCard = Nothing ThisHole = Nothing End Try End Sub
I make use of the regular expression engine to validate dates and some key presses. I spent much of Chapter 9 on regular expressions. I must tell you that it really does pay to learn this syntax. It can save so much code.
If the user decides he or she wants to enter statistics for each hole played, he or she needs only double-click in one of the cells that holds the hole score. The user will then see the screen shown in Figure 11-10.
This detail screen is the user interface to the IHoleDetail object. The user is not able to enter any value for the yardage. The program does that when the user chooses a radio button. This is restrictive data entry, and there is no reason to add any validation handlers for this data. Whatever the user chooses as his or her tee color is correct.
The Shots GroupBox contains several fields. I decided to use ComboBoxes for the clubs as these are always known quantities. The Shots To Green and Putts fields could be any numeric value. The Total Shots field is the same.
I make good use of controls that steer the user to a predetermined valid entry. The RadioBoxes are not gratuitous and scattered. CheckBoxes are used as Boolean fields to note the quality of the shot. No elaboration is needed here.
Three fields need validation. I first set the field length to 3 for each of these fields. If someone makes more than 99 shots on any one hole, that person needs a new hobby. Next, I trap the KeyPress event to make sure that only numbers are allowed. Listings 11-5a and 11-5b show the constructor and delegates for this form.
public HoleDetail(ref ICourseInfo ThisCourse, ref IHoleDetailInfo hole) { InitializeComponent(); //Don't forget to initialize tab order and speedkeys, etc. this.BackColor = Color.LightGreen; cmdSave.BackColor = Color.SandyBrown; cmdCancel.BackColor = Color.SandyBrown; mHole = hole; lblCourse.Text = ThisCourse.Name; foreach(Tees tee in ThisCourse.Hole) { if(tee.HoleNumber == mHole.Hole) { distance = tee; break; } } lblHole.Text = "Hole: " + mHole.Hole.ToString(); lblPar.Text = "Par: " + mHole.Par.ToString(); optBlue.CheckedChanged += new EventHandler(this.YardClick); optWhite.CheckedChanged += new EventHandler(this.YardClick); optRed.CheckedChanged += new EventHandler(this.YardClick); switch(hole.TeeBox) { case YardMarker.Blue: optBlue.Checked = true; TeeBox = YardMarker.Blue; break; case YardMarker.Red: optRed.Checked = true; TeeBox = YardMarker.Red; break; case YardMarker.White: default: optWhite.Checked = true; TeeBox = YardMarker.White; break; } //This is how you enumerate an enumeration //Bet you didn't know you could do this. GolfClubs G = GolfClubs.One_iron; while(G <= GolfClubs.Putter) { cmbFirstClub.Items.Add(G); if(mHole.TeeClub == G) cmbFirstClub.SelectedIndex = cmbFirstClub.Items.Count-1; cmbSecondClub.Items.Add(G); if(mHole.ScondClub == G) cmbSecondClub.SelectedIndex = cmbSecondClub.Items.Count-1; G++; } cmdSave.DialogResult = DialogResult.OK; cmdCancel.DialogResult = DialogResult.Cancel; cmdSave.Click += new EventHandler(SaveHole); chkFairway.Checked = hole.HitFairway; chkGoodHit.Checked = hole.GoodSecondShot; txtShots2Green.MaxLength = 3; txtShots2Green.Text = hole.ShotsToGreen.ToString(); txtShots2Green.KeyPress += new KeyPressEventHandler(OnlyNumbers); txtPutts.MaxLength = 3; txtPutts.Text = hole.Putts.ToString(); txtPutts.KeyPress += new KeyPressEventHandler(OnlyNumbers); txtTotal.MaxLength = 3; txtTotal.Text = hole.TotalShots.ToString(); txtTotal.KeyPress += new KeyPressEventHandler(OnlyNumbers); //Consider adding a databinding or validation to the totals //text box so it automatically totals the shots2green and putts TextBoxes. } #region events private void OnlyNumbers(object sender, KeyPressEventArgs e) { //Allow only positive numbers if(!char.IsDigit(e.KeyChar)) e.Handled = true; } private void YardClick(object sender, EventArgs e) { if(optBlue.Checked) { txtYards.Text = distance.BlueDistance.ToString(); TeeBox = YardMarker.Blue; } else if(optWhite.Checked) { txtYards.Text = distance.WhiteDistance.ToString(); TeeBox = YardMarker.White; } else if(optRed.Checked) { txtYards.Text = distance.RedDistance.ToString(); TeeBox = YardMarker.Blue; } } private void SaveHole(object sender, EventArgs e) { if(txtPutts.Text != string.Empty) mHole.Putts = int.Parse(txtPutts.Text); else mHole.Putts = 0; if(txtShots2Green.Text != string.Empty) mHole.ShotsToGreen = int.Parse(txtShots2Green.Text); else mHole.ShotsToGreen = 0; if(txtTotal.Text != string.Empty) mHole.TotalShots = int.Parse(txtTotal.Text); else mHole.TotalShots = 0; mHole.GoodSecondShot = chkFairway.Checked; mHole.GoodSecondShot = chkGoodHit.Checked; mHole.TeeBox = TeeBox; mHole.TeeClub = (GolfClubs)cmbFirstClub.SelectedItem; mHole.ScondClub = (GolfClubs)cmbSecondClub.SelectedItem; } #endregion
Public Sub New(ByRef ThisCourse As ICourseInfo, ByRef hole As IHoleDetailInfo) MyBase.New() 'This call is required by the Windows Form Designer. InitializeComponent() 'Don't forget to initialize tab order and speedkeys, etc. BackColor = Color.LightGreen cmdSave.BackColor = Color.SandyBrown cmdCancel.BackColor = Color.SandyBrown mHole = hole lblCourse.Text = ThisCourse.Name Dim Tee As Tees For Each Tee In ThisCourse.Hole If Tee.HoleNumber = mHole.Hole Then distance = Tee Exit For End If Next lblHole.Text = "Hole: " + mHole.Hole.ToString() lblPar.Text = "Par: " + mHole.Par.ToString() AddHandler optBlue.CheckedChanged, New EventHandler(AddressOf YardClick) AddHandler optWhite.CheckedChanged, New EventHandler(AddressOf YardClick) AddHandler optRed.CheckedChanged, New EventHandler(AddressOf YardClick) Select Case hole.TeeBox Case YardMarker.Blue optBlue.Checked = True TeeBox = YardMarker.Blue Case YardMarker.Red optRed.Checked = True TeeBox = YardMarker.Red Case YardMarker.White optWhite.Checked = True TeeBox = YardMarker.White End Select 'This is how you enumerate an enumeration 'Bet you didn't know you could do this. Dim G As GolfClubs = GolfClubs.One_iron While G <= GolfClubs.Putter cmbFirstClub.Items.Add(G) If mHole.TeeClub = G Then cmbFirstClub.SelectedIndex = cmbFirstClub.Items.Count - 1 End If cmbSecondClub.Items.Add(G) If mHole.ScondClub = G Then cmbSecondClub.SelectedIndex = cmbSecondClub.Items.Count - 1 End If G += 1 End While cmdSave.DialogResult = DialogResult.OK cmdCancel.DialogResult = DialogResult.Cancel AddHandler cmdSave.Click, New EventHandler(AddressOf SaveHole) chkFairway.Checked = hole.HitFairway chkGoodHit.Checked = hole.GoodSecondShot txtShots2Green.MaxLength = 3 txtShots2Green.Text = hole.ShotsToGreen.ToString() AddHandler txtShots2Green.KeyPress, _ New KeyPressEventHandler(AddressOf OnlyNumbers) txtPutts.MaxLength = 3 txtPutts.Text = hole.Putts.ToString() AddHandler txtPutts.KeyPress, _ New KeyPressEventHandler(AddressOf OnlyNumbers) txtTotal.MaxLength = 3 txtTotal.Text = hole.TotalShots.ToString() AddHandler txtTotal.KeyPress, _ New KeyPressEventHandler(AddressOf OnlyNumbers) 'Consider adding a databinding or validation to the totals text box 'so it automatically totals the shots2green and putts TextBoxes. End Sub #Region "events" Private Sub OnlyNumbers(ByVal sender As Object, ByVal e As KeyPressEventArgs) 'Allow only positive numbers If Not Char.IsDigit(e.KeyChar) Then e.Handled = True End If End Sub Private Sub YardClick(ByVal sender As Object, ByVal e As EventArgs) If optBlue.Checked Then txtYards.Text = distance.BlueDistance.ToString() TeeBox = YardMarker.Blue ElseIf optWhite.Checked Then txtYards.Text = distance.WhiteDistance.ToString() TeeBox = YardMarker.White ElseIf optRed.Checked Then txtYards.Text = distance.RedDistance.ToString() TeeBox = YardMarker.Blue End If End Sub Private Sub SaveHole(ByVal sender As Object, ByVal e As EventArgs) mHole.GoodSecondShot = chkFairway.Checked mHole.GoodSecondShot = chkGoodHit.Checked mHole.TeeBox = TeeBox mHole.TeeClub = CType(cmbFirstClub.SelectedItem, GolfClubs) If txtPutts.Text = String.Empty Then mHole.Putts = 0 Else mHole.Putts = Int32.Parse(txtPutts.Text) End If mHole.ScondClub = CType(cmbSecondClub.SelectedItem, GolfClubs) If txtShots2Green.Text = String.Empty Then mHole.ShotsToGreen = 0 Else mHole.ShotsToGreen = Int32.Parse(txtShots2Green.Text) End If If txtTotal.Text = String.Empty Then mHole.TotalShots = 0 Else mHole.TotalShots = Int32.Parse(txtTotal.Text) End If End Sub #End Region
You can see that the code for this form is not difficult at all. In fact, proper use of controls has actually eliminated quite a bit of validation code.
Note |
I did not include any tab index or speed key setup. I trust that you know to do this as a first step in aiding the user to navigate your screens. |
The last screen I present in this project is the Statistics screen. In this screen I show the avid golfer where his or her game falls short (or left, or right, or in the pond, or …). Figure 11-11 shows this screen with some scorecards added to the original one.
I will tell you now that I did not add the code for the Totals GroupBox statistics. I also did not calculate the raw handicap. My intention with this form is to show you how to present a different view to the user.
You have seen this view in the form of the DataGrid page. There I displayed the scorecard in a row-by-row format. Each cell contained only some of the information you see in Figure 11-11. For instance, the date was a cell in the DataGrid page, and here it is a node in a tree. Also, the only detailed information I gave in the DataGrid form was the individual hole total. You could gather some of this information from the DataGrid using a calculator and a piece of paper, but that is not what that view is for.
Let's look at how I set up this form. Listings 11-6a and 11-6b show all the relevant code for this form.
#region locals private GolfStat mParent; private ICourseInfo ThisCourse; private IScoreCardInfo ThisCard; private IHoleDetailInfo ThisHole; #endregion public Statistics() { InitializeComponent(); Init(); } private void Statistics_Load(object sender, System.EventArgs e) { mParent = (GolfStat)this.MdiParent; SetupTree(); } #region Setup private void Init() { this.BackColor = Color.LightGreen; cmdClose.BackColor = Color.SandyBrown; cmdClose.Text = "&Close"; cmdClose.TabIndex = 0; cmdClose.Click += new EventHandler(CloseMe); imgIcons.Images.Add(Image.FromFile("flag.ico")); } private void SetupTree() { TreeNode CourseNode; TreeNode CardNode; tvwCourse.Nodes.Clear(); tvwCourse.BeginUpdate(); tvwCourse.ImageList = imgIcons; tvwCourse.ImageIndex = 0; tvwCourse.SelectedImageIndex = 0; foreach(ICourseInfo c in mParent.db.GolfCourses) { CourseNode = tvwCourse.Nodes.Add(c.Name); CourseNode.Tag = c; foreach(IScoreCardInfo card in c.ScoreCards) { CardNode = CourseNode.Nodes.Add(card.PlayDate.ToShortDateString()); CardNode.Tag = card; } } tvwCourse.AfterSelect += new TreeViewEventHandler(CourseStats); tvwCourse.EndUpdate(); } #endregion #region events public void CloseMe(object sender, EventArgs e) { this.Close(); } private void CourseStats(object s, TreeViewEventArgs e) { float FairwaysHit = 0; float GreensInReg = 0; float Putting = 0; float MaxPutts = 0; float MinPutts = 99; float AvgPutts = 0; if(e.Node.Tag is IScoreCardInfo) { ThisCard = (IScoreCardInfo)e.Node.Tag; foreach(IHoleDetailInfo h in ThisCard.holes) { FairwaysHit += h.HitFairway ? 1 : 0; GreensInReg += h.GreenInReg ? 1 : 0; Putting += h.Putts; if(h.Putts > MaxPutts) MaxPutts = h.Putts; if(h.Putts < MinPutts) MinPutts = h.Putts; AvgPutts += h.Putts; } FairwaysHit /= ThisCard.holes.Count; Putting /= (ThisCard.holes.Count * 2); GreensInReg /= ThisCard.holes.Count; AvgPutts /= ThisCard.holes.Count; lblFairwaysHit.Text = (FairwaysHit*100).ToString(); lblGreens.Text = (GreensInReg*100).ToString(); lblPutting.Text = (Putting*100).ToString(); lblMaxPutts.Text = MaxPutts.ToString(); lblAvgPutts.Text = AvgPutts.ToString(); lblMinPutts.Text = MinPutts.ToString(); //I will let you figure out the rest of the stats. :) } } #endregion
#Region "locals" Private mParent As GolfStat Private ThisCourse As ICourseInfo Private ThisCard As IScoreCardInfo Private ThisHole As IHoleDetailInfo #End Region Public Sub New() MyBase.New() 'This call is required by the Windows Form Designer. InitializeComponent() Init() End Sub Private Sub Statistics_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load mParent = CType(Me.MdiParent, GolfStat) SetupTree() End Sub #Region "Setup" Private Sub Init() BackColor = Color.LightGreen cmdClose.BackColor = Color.SandyBrown cmdClose.Text = "&Close" cmdClose.TabIndex = 0 AddHandler cmdClose.Click, New EventHandler(AddressOf CloseMe) imgIcons.Images.Add(Image.FromFile("flag.ico")) End Sub Private Sub SetupTree() Dim CourseNode As TreeNode Dim CardNode As TreeNode tvwCourse.Nodes.Clear() tvwCourse.BeginUpdate() tvwCourse.ImageList = imgIcons tvwCourse.ImageIndex = 0 tvwCourse.SelectedImageIndex = 0 Dim c As ICourseInfo For Each c In mParent.db.GolfCourses CourseNode = tvwCourse.Nodes.Add(c.Name) CourseNode.Tag = c Dim card As IScoreCardInfo For Each card In c.ScoreCards CardNode = CourseNode.Nodes.Add(card.PlayDate.ToShortDateString()) CardNode.Tag = card Next Next AddHandler tvwCourse.AfterSelect, _ New TreeViewEventHandler(AddressOf CourseStats) tvwCourse.EndUpdate() End Sub #End Region #Region "events" Public Sub CloseMe(ByVal s As Object, ByVal e As EventArgs) Close() End Sub Private Sub CourseStats(ByVal s As Object, ByVal e As TreeViewEventArgs) Dim FairwaysHit As Single = 0 Dim GreensInReg As Single = 0 Dim Putting As Single = 0 Dim MaxPutts As Single = 0 Dim MinPutts As Single = 99 Dim AvgPutts As Single = 0 If TypeOf (e.Node.Tag) Is IScoreCardInfo Then ThisCard = CType(e.Node.Tag, IScoreCardInfo) Dim h As IHoleDetailInfo For Each h In ThisCard.holes If h.HitFairway Then FairwaysHit += 1 If h.GreenInReg Then GreensInReg += 1 Putting += h.Putts If h.Putts > MaxPutts Then MaxPutts = h.Putts If h.Putts < MinPutts Then MinPutts = h.Putts AvgPutts += h.Putts Next FairwaysHit /= ThisCard.holes.Count Putting /= (ThisCard.holes.Count * 2) GreensInReg /= ThisCard.holes.Count AvgPutts /= ThisCard.holes.Count lblFairwaysHit.Text = (FairwaysHit * 100).ToString() lblGreens.Text = (GreensInReg * 100).ToString() lblPutting.Text = (Putting * 100).ToString() lblMaxPutts.Text = MaxPutts.ToString() lblAvgPutts.Text = AvgPutts.ToString() lblMinPutts.Text = MinPutts.ToString() 'I will let you figure out the rest of the stats. :) End If End Sub #End Region
This code enumerates through each scorecard in each golf course and displays them as nodes in the tree. As the user clicks a scorecard node, I calculate some statistics and show them on the screen.
Here is the code snippet to fill the tree:
foreach(ICourseInfo c in mParent.db.GolfCourses) { CourseNode = tvwCourse.Nodes.Add(c.Name); CourseNode.Tag = c; foreach(IScoreCardInfo card in c.ScoreCards) { CardNode = CourseNode.Nodes.Add(card.PlayDate.ToShortDateString()); CardNode.Tag = card; } }
You can see from this code that I use the node's Tag property to contain the actual object in question. As the user selects a node in the tree, I test the Tag object to see if it is an IScoreCardInfo object. If so, I gather some aggregate statistics for each hole played for that scorecard.
[4]Refresh your memory about the difference between a class and an interface.