Tuesday, February 15, 2011

Silverlight 4.0 Tutorial (12 of N): Collecting Attendees Feedback using Windows Phone 7

You can find a list of the previous tutorial posts here

Continuing our RegistrationBooth Application…

It’s very important to collect the attendees feedback to help us improve the future sessions/events we might organize, we can go ahead and implement this functionality in the RegistrationBooth application. but in this post we will take a different approach, we will implement a Windows Phone 7 application that attendees can use on their phones to submit their feedback. Luckily for us Windows Phone 7 uses Silverlight as its development platform (along with XNA), the Silverlight version on Windows Phone is not the same one as the desktop version, think of this as a compact version that fits the phone capabilities.

Before we start creating the phone application, first let’s think how we will bring the data to the phone, in the case of the Silverlight application we used WCF RIA services (DomainService) which provided rich client experience for the Silverlight application, Unfortunately this type of rich client experience is not available “yet” for windows phone applications.

When we were creating the DomainService we had the option to expose OData end point.

Exposing OData end point for the Domain Service

If life was peachy we would just add a new OData endpoint to the Domain service we have and consume this service from Windows Phone, unfortunately the OData endpoint exposed from the RIA Service lacks important feature (query options) so we will not be able to make use of an OData endpoint over the RIA service, the solution is to create a new WCF data service, we will call it RegistrationBoothDataService

Add a new WCF Data Service

Change the Data Service to use our ADO.NET entity model and in the InitializeService method enable access to all the entities, the service should look like the following

public class RegistrationBoothDataService :
    DataService<RegistrationBoothDBEntities>
{
    public static void InitializeService
        (DataServiceConfiguration config)
    {
        config.SetEntitySetAccessRule("*",
            EntitySetRights.All);
        config.SetServiceOperationAccessRule("*",
            ServiceOperationRights.All);
        config.DataServiceBehavior.MaxProtocolVersion =
            DataServiceProtocolVersion.V2;
    }
}

Now build the project and give it a try and navigate to the following URL

http://localhost:57263/RegistrationBoothDataService.svc/

You will see AtomPub feed containing the names of all the entities exposed by the DomainService, if you want to see the records of a certain entity just append entityname to the above URL, for example to see all the attendees go to the following URL

http://localhost:57263/RegistrationBoothDataService.svc/Attendees

That’s what we need to do to configure the service on the server side.

Now moving to the client (Phone) we need to add a new project (for the Windows Phone 7 development tools go to http://developer.windowsphone.com) We will choose the Windows Phone Panorama Application project type, later we will see why we choose that.

Creating a Windows Phone Panorama Application

To consume the OData service from the windows phone project we need to download the OData client library for Windows Phone 7 (http://odata.codeplex.com), when you download this library you will have the assembly System.Data.Services.Client.dll, Add Reference to this dll in the windows phone project, and you will have a command line tool DataSvcUtil.exe that we will use to generate a proxy class for the OData service, so we will run the command line tool passing the service URL

DataSvcUtil /uri:http://localhost:57263/RegistrationBoothDataService.svc/ /out:.\RegistrationBoothDataModel.cs /Version:2.0 /DataServiceCollection

Once we have this class, we will add it to the windows phone project.

So why we chose the panoramic application? or what is a panoramic application anyway? according to MSDN “panoramic applications offer a unique way to view controls, data, and services by using a long horizontal canvas that extends beyond the confines of the screen”

Panorama Application

So how we will implement our application UI as a panorama application? we will  display the time slots horizontally and the sessions vertically, so the user will see the time slot in the header and below it a list of sessions, and by scrolling horizontally the user will move to the subsequent time slots and below each one the list of sessions in that time slot.

If you inspect the panorama application project we created, you will find that VS created a sample project with organized structure, where you have view models located in the ViewModels folder and the view models contain some sample data, and the XAML pages (views) are bound to these view models, we will follow the same structure in our application.

To communicate with the data service we need to define a DataServiceContext we will define this in the Application class

//App.xaml.cs
static readonly Uri ServiceUri = new Uri("http://localhost:57263/RegistrationBoothDataService.svc/");
public static int AttendeeID { get; set; }
       
static RegistrationBoothDataService context;
public static RegistrationBoothDataService DataServiceContext
{
    get
    {
        if (context == null)
            context = new RegistrationBoothDataService(ServiceUri);
        return context;
    }
}

The first page will be the Login.xaml, for the sake of simplicity we will ask the user to enter the attendee Id only

Login Page

The code behind is pretty straightforward, in the page constructor we initialize a DataServiceCollection of Attendees

static readonly string attendeesUri = "/Attendees?$filter=Id eq {0}";
DataServiceCollection<Attendee> attendeesEntities;
public Login()
{
    InitializeComponent();
    attendeesEntities = new DataServiceCollection<Attendee>(App.DataServiceContext);
    attendeesEntities.LoadCompleted += new EventHandler<LoadCompletedEventArgs>(attendeesEntities_LoadCompleted);
    
}

When the user clicks the login button we populate this collection by a query that filters the attendees based on the supplied attendee id "/Attendees?$filter=Id eq {0}"

private void LoginButton_Click(object sender, RoutedEventArgs e)
{
    attendeesEntities.LoadAsync(new Uri(string.Format(attendeesUri,AttendeeID.Text), UriKind.Relative));
}

Once the query is loaded we check to see if there’s any attendee returned in the result, if there is an attendee we save the AttendeeID and navigate to the next page

void attendeesEntities_LoadCompleted(object sender, LoadCompletedEventArgs e)
{
    if (attendeesEntities.Count != 1)
        MessageBox.Show("Invalid id");
    else
    {
        App.AttendeeID = attendeesEntities[0].Id;
        this.NavigationService.Navigate(
    new Uri("/MainPage.xaml", UriKind.Relative));
    }
                
}

The next page MainPage.xaml will display the sessions in a panorama view as we described earlier. before we build the UI let’s first create the view model, the first view model is the TimeSlotViewModel which contains two properties the TimeSlotHeader and a collection of Sessions, the view model also implements the INotifyPropertyChanged interface

public class TimeSlotViewModel : INotifyPropertyChanged
{
    public TimeSlotViewModel(TimeSlot timeSlotEntity)
    {
        this.TimeSlotHeader = String.Format("{0:HH:mm tt} - {1:HH:mm tt}", timeSlotEntity.StartTime, timeSlotEntity.EndTime);
        this.Sessions = timeSlotEntity.Sessions;
    }

    Collection<Session> sessions;
    public Collection<Session> Sessions
    {
        get { return this.sessions; }
        private set
        {
            this.sessions = value;
            this.NotifyPropertyChanged("Sessions");
        }
    }

    string timeSlotHeader;
    public string TimeSlotHeader
    {
        get
        { return this.timeSlotHeader; }
        private set
        {
            this.timeSlotHeader = value;
            this.NotifyPropertyChanged("TimeSlotHeader");
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    private void NotifyPropertyChanged(String propertyName)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (null != handler)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Another view model we have is the MainViewModel, this is the view model that the MainPage.xaml will bind to, it has an ObservableCollection of TimeSlotViewModel objects, we populate this collection by performing a query against the DataService using the Uri “/TimeSlots?$expand=Sessions/Speakers” this query expands the TimeSlot entity to retrieve the associated Sessions and Speakers entities as well

public class MainViewModel : INotifyPropertyChanged
{
    static readonly Uri timeSlotsUri = new Uri("/TimeSlots?$expand=Sessions/Speakers", UriKind.Relative);
        
    DataServiceCollection<TimeSlot> timeSlotsEntities;
    ObservableCollection<TimeSlotViewModel> timeSlots;

    public MainViewModel()
    {
        timeSlotsEntities = new DataServiceCollection<TimeSlot>(App.DataServiceContext);
        timeSlotsEntities.LoadCompleted += new EventHandler<LoadCompletedEventArgs>(timeSlotsEntities_LoadCompleted);
        this.TimeSlots = new ObservableCollection<TimeSlotViewModel>();
    }
    public ObservableCollection<TimeSlotViewModel> TimeSlots
    {
        get { return this.timeSlots; }
        private set
        {
            this.timeSlots = value;
            this.NotifyPropertyChanged("TimeSlots");
        }
    }
    public bool IsDataLoaded
    {
        get; private set;
    }
    public void LoadData()
    {
        timeSlotsEntities.LoadAsync(timeSlotsUri);
        this.IsDataLoaded = true;
    }
    void timeSlotsEntities_LoadCompleted(object sender, LoadCompletedEventArgs e)
    {
        foreach (var timeSlot in timeSlotsEntities)
        {
            var tsViewModel = new TimeSlotViewModel(timeSlot);
            this.TimeSlots.Add(tsViewModel);
        }
    }

The MainPage.xaml contains the Panorama control whose ItemsSource property is bound to the TimeSlots property of the MainViewModel, each panorama Item contains a ListBox that displays the Sessions available in that TimeSlot

<Grid x:Name="LayoutRoot" Background="Transparent">
        <Grid.Resources>
            <local:DisplayNameConverter x:Key="displayNameConverter" />
        </Grid.Resources>
       <controls:Panorama Title="Select the Session"
                          ItemsSource="{Binding TimeSlots}">
            <controls:Panorama.Background>
                <ImageBrush ImageSource="PanoramaBackground.png"/>
            </controls:Panorama.Background>
            
            <controls:Panorama.ItemTemplate>
                <DataTemplate>
                    <controls:PanoramaItem Header="{Binding TimeSlotHeader}" >
                        <controls:PanoramaItem.HeaderTemplate>
                            <DataTemplate>
                                <TextBlock Text="{Binding}" Style="{StaticResource PhoneTextLargeStyle}"/>
                            </DataTemplate>
                        </controls:PanoramaItem.HeaderTemplate>
                        <ListBox Margin="0,0,-12,0" ItemsSource="{Binding Sessions}" SelectionChanged="OnSelectionChanged">
                    <ListBox.ItemTemplate>
                        <DataTemplate>
                           
                            <StackPanel Margin="0,0,0,17" Width="432">
                                <TextBlock Text="{Binding Title}" TextWrapping="Wrap"
                                            Style="{StaticResource PhoneTextLargeStyle}"/>
                                <ListBox ItemsSource="{Binding Speakers}">            
                                    <ListBox.ItemTemplate>
                                        <DataTemplate>
                                            <TextBlock Text="{Binding ., Converter={StaticResource displayNameConverter}}" TextWrapping="Wrap"
                                                            Style="{StaticResource PhoneTextSubtleStyle}" />
                                        </DataTemplate>
                                    </ListBox.ItemTemplate>
                                </ListBox>
                            </StackPanel>
                        </DataTemplate>
                    </ListBox.ItemTemplate>
                </ListBox>
                    </controls:PanoramaItem>
                </DataTemplate>
            </controls:Panorama.ItemTemplate>
        </controls:Panorama>
    </Grid>

The code behind the MainPage.xaml will set the DataContext of the page to an instance of the MainViewModel class, we will load the View Model data in the Load Event of the page, on the SelectionChanged event of the sessions ListBox we will navigate to the session evaluation page SessionEval.xaml

public partial class MainPage : PhoneApplicationPage
{
    MainViewModel ViewModel = new MainViewModel();
    public MainPage()
    {
        InitializeComponent();
        DataContext = ViewModel;
        this.Loaded += new RoutedEventHandler(MainPage_Loaded);
    }
    private void MainPage_Loaded(object sender, RoutedEventArgs e)
    {
        if (!ViewModel.IsDataLoaded)
        {
            ViewModel.LoadData();
        }
    }
    void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        var selector = (Selector)sender;
        if (selector.SelectedIndex == -1)
            return;
        Session session = selector.SelectedItem as Session;
        this.NavigationService.Navigate(
            new Uri("/SessionEval.xaml?sessionId=" + session.Id, UriKind.Relative));
        selector.SelectedIndex = -1;
    }
}

The view model for the SessionEval.xaml page is called SessionEvalViewModel this view model has a punch of propertyies we have a DataServiceCollection of EvalCriteria entities which contains the different evaluation criteria, and we have another DataServiceCollection of SessionEvaluation entities which contains the evaluation value for each criteria per session per attendee. there is another ObservableCollection of EvalCriteriaInfo objects, this collection is the one that is exposed to the UI.

static readonly string evalCriteriaUrl = "/EvalCriterias";
static readonly string sessionEvalUrl = "/SessionEvaluations?$filter=SessionId eq {0} and AttendeeId eq {1}";//, UriKind.Relative);
DataServiceCollection<EvalCriteria> evalCriteriaEntities;
DataServiceCollection<SessionEvaluation> sessionEvalEntities;
bool bEvalsLoaded = false;
int _sessionId;
bool bCriteriaLoaded = false;
ObservableCollection<EvalCriteriaInfo> _evaluations;

public SessionEvalViewModel()
{            
        _evaluations = new ObservableCollection<EvalCriteriaInfo>();
}

#region Properties
public ObservableCollection<EvalCriteriaInfo> Evaluations
{
    get { return _evaluations; }
    set
    {
        _evaluations = value;
        NotifyPropertyChanged("Evaluations");
    }
}
public bool IsDataLoaded
{
    get;
    private set;
}
#endregion

the EvalCriteriaInfo class is a helper class that implements the INotifyPropertyChanged interface

public class EvalCriteriaInfo : INotifyPropertyChanged
{
    string _evalCriteriaTitle;
    int _evalCriteriaId;
    int _evaluationValue;
    public string EvalCriteriaTitle {
        get { return _evalCriteriaTitle; }
        set
        {
            _evalCriteriaTitle = value;
            NotifyPropertyChanged("EvalCriteriaTitle");
        }
    }
    public int EvaluationValue
    {
        get { return _evaluationValue; }
        set
        {
            _evaluationValue = value;
            NotifyPropertyChanged("EvaluationValue");
        }
    }
    public int EvalCriteriaId
    {
        get { return _evalCriteriaId; }
        set
        {
            _evalCriteriaId = value;
            NotifyPropertyChanged("EvalCriteriaId");
        }
    }
      
    public SessionEvaluation SessionEvaluationEntity;

    #region INotifyPropertyChanged Implementation
    /**/
    #endregion
}

The LoadData method of the view model, loads the EvalCriteria and SessionEvaluation collections and save them in the Application object

public void LoadData(int sessionId)
{
    _sessionId = sessionId;
    if (App.EvalCriteriaEntities == null)
    {
        evalCriteriaEntities = new DataServiceCollection<EvalCriteria>(App.DataServiceContext);
        evalCriteriaEntities.LoadCompleted += new EventHandler<LoadCompletedEventArgs>(evalCriteriaEntities_LoadCompleted);
        evalCriteriaEntities.LoadAsync(new Uri(evalCriteriaUrl, UriKind.Relative));
    }
    else
    {
        this.evalCriteriaEntities = App.EvalCriteriaEntities;
        bCriteriaLoaded = true;
    }

    if (!App.SessionEvaluationEntities.ContainsKey(this._sessionId))
    {
        sessionEvalEntities = new DataServiceCollection<SessionEvaluation>(App.DataServiceContext);
        sessionEvalEntities.LoadCompleted += new EventHandler<LoadCompletedEventArgs>(sessionEvalEntities_LoadCompleted);
        sessionEvalEntities.LoadAsync(new Uri(string.Format(sessionEvalUrl, sessionId, App.AttendeeID), UriKind.Relative));
    }
    else
    {
        this.sessionEvalEntities = App.SessionEvaluationEntities[this._sessionId];
        bEvalsLoaded = true;
    }
    this.IsDataLoaded = true;
    populate();
}
void evalCriteriaEntities_LoadCompleted(object sender, LoadCompletedEventArgs e)
{
    bCriteriaLoaded = true;
    App.EvalCriteriaEntities = evalCriteriaEntities;
    populate();
}
void sessionEvalEntities_LoadCompleted(object sender, LoadCompletedEventArgs e)
{
    bEvalsLoaded = true;
    App.SessionEvaluationEntities.Add(_sessionId,sessionEvalEntities);
    populate();
}
void populate()
{
    if (!bEvalsLoaded || !bCriteriaLoaded)
        return;
            
    foreach (var evalCriterion in evalCriteriaEntities)
    {
        var evalInfo = new EvalCriteriaInfo();
        evalInfo.EvalCriteriaId = evalCriterion.EvalCriterionId;
        evalInfo.EvalCriteriaTitle = evalCriterion.EvalCriterionText;
                                
        foreach (var sessionEval in sessionEvalEntities)
        {
            if (sessionEval.EvalCriterionId == evalCriterion.EvalCriterionId)
            {
                evalInfo.EvaluationValue = sessionEval.Evaluation-1;
                evalInfo.SessionEvaluationEntity  = sessionEval;
            }
        }
                
        Evaluations.Add(evalInfo);  
    }
    NotifyPropertyChanged("Evaluations");
}

The Save method will update the corresponding SessionEvaluation entity if there’s an existing one, or it will add a new SessionEvaluation entity, the changes are pushed back to the Data Service by calling the DataServiceContext BeginSaveChanges method

public void Save()
        {
            foreach (var eval in this.Evaluations)
            {
                if (eval.SessionEvaluationEntity != null)
                {
                    eval.SessionEvaluationEntity.Evaluation = eval.EvaluationValue + 1;
                    App.DataServiceContext.UpdateObject(eval.SessionEvaluationEntity);
                }
                else
                {
                    SessionEvaluation sessionEval = new SessionEvaluation();
                    sessionEval.AttendeeId = App.AttendeeID;
                    sessionEval.EvalCriterionId = eval.EvalCriteriaId;
                    sessionEval.Evaluation = eval.EvaluationValue+1;
                    sessionEval.SessionId = _sessionId;
                    App.DataServiceContext.AddObject("SessionEvaluations", sessionEval);
                }
            }
            App.DataServiceContext.BeginSaveChanges(OnSaveCompleted, null);
        }

The code behind in the SessionEval page will set the DataContext of the page to an instance of the SessionEvalViewModel class, we will load the View Model data in the NavigateTo event handler the page. When we click the Save button we call the Save method of the view model

public partial class SessionEval : PhoneApplicationPage
{
    SessionEvalViewModel viewModel=new SessionEvalViewModel();
    public SessionEval()
    {
        InitializeComponent();
        DataContext = viewModel;
    }
    protected override void OnNavigatedTo(NavigationEventArgs e)
    {
        string strIndex = this.NavigationContext.QueryString["sessionId"];
        int sessionId = int.Parse(strIndex);
        if (!viewModel.IsDataLoaded)
        {
            viewModel.LoadData(sessionId);
        }
    }
    void OnSave(object sender, EventArgs e)
    {
        this.viewModel.Save();
    }
}

The SessionEval.xaml page contains a ListBox that is bound to the Evaluations collection, each list item will have a group of radio buttons that display the evaluation values.

<ListBox Margin="0,0,-12,0" x:Name="lstEvaluations"
            ItemsSource="{Binding Evaluations,Mode=TwoWay}" >
    <ListBox.ItemTemplate>
        <DataTemplate>

            <StackPanel Margin="0,0,0,17" Width="432">
                <TextBlock Text="{Binding EvalCriteriaTitle}" TextWrapping="Wrap"
                                Style="{StaticResource PhoneTextLargeStyle}"/>
                <ListBox SelectedIndex="{Binding EvaluationValue, Mode=TwoWay}"
                            ItemContainerStyle="{StaticResource RadioButtonListItemStyle}">
                    <ListBoxItem Content="1"/>
                    <ListBoxItem Content="2"/>
                    <ListBoxItem Content="3"/>
                    <ListBoxItem Content="4"/>
                </ListBox>
            </StackPanel>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

You can build and run the project to see the application in action

Main page displaying the Sessions in Panorama control

 

Session evaluation page

You can download the source code from here