Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

The Big Bang Transcripts Viewer

0.00/5 (No votes)
12 Jan 2012 1  
Learn how to download your favorite TV transcripts and format it in your Smartphone.

Table of Contents

Introduction

After playing with my first Smartphone for a couple of months so far, I finally realised how much potential there is in it regarding reading content from the web in a fast and easy way. Today, many websites provide mobile-friendly features (for example, you can read The Code Project articles with the "mobile" switch on). As a result, you end up with a cleaner, faster version of the article, much more appropriate for the Smartphone form factor, as an alternative to that chunky, confusing full web page that was designed for 19" PC screens.

But even with that option for mobile version, I'm not always that satisfied with the results, depending on the kind of content I'm reading. I'll give an example: I love watching TV shows and as a non-native English speaker I often find myself lost in many of the jokes. So I like reading the TV show transcripts, specially the transcripts of this particular blog. But if while watching the TV show I lose the jokes, reading the TV transcripts in plain text should be more fun. Why not format the dialogues as a dialogue thread, with pictures of characters and speech balloons? This idea gave birth to a free app I published on the Windows Phone Marketplace and, finally, this article on The Code Project.

System Requirements

To use The Big Bang Transcripts provided with this article, you must have installed the Windows Phone SDK 7.1 that you can download 100% free directly from Microsoft:

Disclaimer

For those who may have some concerns regarding the copyright issues: what I'm doing here is simply reading and formatting the contents already provided by this particular blog. As happens with any web browser app, this app is just a reader, and no text content is provided with the source project uploaded with this article.

The WebClient Component: Downloading Content From the Web

The WebClient Silverlight component stands as the bare bones of the web content consuming technique in the Windows Phone platform. In our project, we are using the DownloadStringAsync function to retrieve the raw content from the blog page/pages.

The function name speaks for itself: DownloadStringAsync downloads string content in an async manner:

WebClient.DownloadStringAsync Method (Uri)

Downloads the resource at the specified URI as a string.
  • Namespace: System.Net
  • Assembly: System.Net (in System.Net.dll)
Syntax
public void DownloadStringAsync(
    Uri address
)
Parameters
  • address: Type: System.Uri. The location of the resource to be downloaded.

Also, it is worth mentioning that the DownloadStringAsync method doesn't work in a traditional sequence of instructions. That is, the instruction following DownloadStringAsync will be executed immediately after the web request is submitted: It will not wait for the content to be downloaded, so don't expect the processing to occur in the same order of the code instructions.

The right way to work with DownloadStringAsync functions is the same you should apply to any other asynchronous request: you must subscribe to the events provided by the WebClient class and so you have control over the order the things may occur. The DownloadStringCompleted event subscription is particularly important, since it gives you the opportunity to tell the program what to do immediately after the content has been completely downloaded. Another useful event is DownloadProgressChanged, which is often used to give users a visual feedback regarding the progress of the download.

private void DownloadString(string url, 
Action<string> onDownloadCompleted, 
Action onConnectionFailed, 
Action<int> onProgressChanged)
{
    try
    {
        var webClient = new WebClient();
        webClient.DownloadStringCompleted += (sender, e) =>
            {
                try
                {
                    onDownloadCompleted(e.Result);
                }
                catch (Exception exc)
                {
                    MessageBox.Show(@"We're sorry, but an unexpected 
                    connection error occurred. Please try again later.", 
                    "Oops!", MessageBoxButton.OK);
                    onConnectionFailed();
                }
            };
        webClient.DownloadProgressChanged += (sender, e) =>
            {
                onProgressChanged(e.ProgressPercentage);
            };
        webClient.AllowReadStreamBuffering = true;
        webClient.DownloadStringAsync(new Uri(url), webClient);
    }
    catch (Exception exc)
    {
        MessageBox.Show(@"We're sorry, but an unexpected 
        connection error occurred. Please try again later.", 
        "Oops!", MessageBoxButton.OK);
        onConnectionFailed();
    }
}

Downloading the Episode List

The first relevant action in our application is to download the episodes list. You may think that right after that we will start downloading all the episodes, but that's not correct: once the episode list is downloaded, the program will wait for the user to open each particular episode to start downloading its transcript. This is so because many users (including me) may be under carrier contract restrictions, and downloading all episodes at one time would unnecessarily increase the carrier usage. I'm sure many (including myself) will prefer to download the episodes while under a WiFi network.

The episode list is embedded in the main blog page's HTML, in such a way that we must extract the relevant data from it by using Regular Expressions. Remember that we work with asynchronous download methods, so once the episode list download is complete, we execute the onContentReady callback. Likewise, we have two more callbacks, one for reporting the download progress and one for the failed connection event.

public void DownloadEpisodeList(Action<List<Episode>> onContentReady, 
        Action onConnectionFailed, Action<int> onProgressChanged)
{
    if (!InternetIsAvailable())
        onConnectionFailed();

    DownloadString("http://bigbangtrans.wordpress.com/",
        (content) =>
        {
            var episodes = new List<Episode>();

            var linkRegex = new Regex("<a[^>]+
            href\\s*=\\s*[\"\\'](?!(?:#|javascript\\s*:))([^\"\\']+)
            [^>]*>(.*?)<\\/a>");
            MatchCollection matches = linkRegex.Matches(content);

            var episodeId = 1;
            for (var i = 0; i < matches.Count; i++)
            {
                var match = matches[i];
                var linkValue = match.Groups[1].Value;
                var nameValue = match.Groups[2].Value.Replace(" ", " ");

                var nameRegex = new Regex("^Series ([0-9]) Episode ([0-9][0-9]) – (.*)");
                if (nameRegex.IsMatch(nameValue))
                {
                    var season = int.Parse(nameRegex.Matches(nameValue)[0].Groups[1].Value);
                    var number = int.Parse(nameRegex.Matches(nameValue)[0].Groups[2].Value);
                    var name = nameRegex.Matches(nameValue)[0].Groups[3].Value;
                    if (nameRegex.IsMatch(nameValue))
                    {
                        episodes.Add(new Episode()
                        {
                            Id = episodeId,
                            Number = number,
                            Description = "",
                            Link = linkValue,
                            Name = name,
                            Season = season,
                            CreatedOn = DateTime.Now
                        });
                    }
                }
                episodeId++;
            }

            onContentReady(episodes);
        },
        () =>
        {
            onConnectionFailed();
        },
        (percentage) => {
            onProgressChanged(percentage);
        });
}

Saving the Episode List Locally

The application relies on the good old technique of serializing/deserializing objects for persisting/retrieving the XML data related to the episode list, episodes, and so on. There are indeed other interesting approaches that could do the job pretty well (such as a local SQL database) but given the simplicity of this application's requirements, the XML paradigm worked well so far, and I don't have plans to change it, at least for a short time.

If you are a seasoned Silverlight developer, needless to say that we are using here the Isolated Storage. If you are not, then Isolated Storage is a closed storage system, or sandboxed system, in which the application is granted access only to the files/folders it has created.

Another relevant aspect of our approach is that we don't generate XML from XML documents. Instead, we instantiate POCOs (Plain Old C# Objects) defined in our model and then serialize them into XML files. I found this approach more flexible and less cumbersome than dealing with XML documents directly. Also, it enables us to do a further refactoring and class decoupling, which is always a good design practice.

Whenever the application is started, it tries to download a new, refreshed version of the episode list, and saves it locally in case of success. But if the download fails, we can go offline and work with the last downloaded episode list. It would be nice if we only downloaded the episode list once and for all, but the blog author is still updating the episode list (and hopefully will keep updating it for many years to come!) so we must keep checking for updates.

public static void SaveEpisodeList(List<Episode> episodes)
{
    StreamResourceInfo streamResourceInfo = 
      Application.GetResourceStream(new Uri(EPISODELIST_FILE_PATH, UriKind.Relative));

    using (IsolatedStorageFile isolatedStorage = 
                IsolatedStorageFile.GetUserStoreForApplication())
    {
        string directoryName = System.IO.Path.GetDirectoryName(EPISODELIST_FILE_PATH);
        if (!string.IsNullOrEmpty(directoryName) && 
               !isolatedStorage.DirectoryExists(directoryName))
        {
            isolatedStorage.CreateDirectory(directoryName);
        }

        isolatedStorage.DeleteFile(EPISODELIST_FILE_PATH);
        Serialize(isolatedStorage, EPISODELIST_FILE_PATH, episodes, typeof(List<Episode>));
    }
}

Downloading an Episode Transcript

Each episode has its own page inside the blog. Remember that the URLs for the episode pages are discovered and stored locally during the last step, while the episode list was being retrieved.

Similarly to what happens with the episode lists, the episode transcripts are found mixed with the rest of the page's HTML, and once again it's up to us to separate the wheat from the chaff. We apply some more Regular Expressions in order to extract the relevant data and then populate the EpisodeTranscript instance with the episode data.

public void DownloadEpisodeTranscript(Episode episode, 
    Action<EpisodeTranscript> onContentReady, 
    Action onConnectionFailed, Action<int> onProgressChanged)
{
    if (!InternetIsAvailable())
        onConnectionFailed();

    DownloadString(episode.Link,
        (content) =>
        {
            var episodeTranscript = new EpisodeTranscript()
            {

                Number = episode.Number,
                Description = episode.Description,
                Link = episode.Link,
                Name = episode.Name,
                Season = episode.Season,
            };

            episodeTranscript.Quotes = new List<Quote>();

            var linkRegex = new Regex("(<p>|<span 
            style=\"font-size:small;font-family:Calibri;\">|<span 
            style=\"font-family:Calibri;\">)(.*?)(<\\/p>|<\\/span>)");
            MatchCollection matches = linkRegex.Matches(content);
                
            for (var i = 0; i < matches.Count; i++)
            {
                var match = matches[i];
                var quoteValue = match.Groups[2].Value;

                quoteValue = quoteValue.Replace("<span>", "");
                quoteValue = quoteValue.Replace("</span>", "");
                quoteValue = quoteValue.Replace("<em>", "");
                quoteValue = quoteValue.Replace("</em>", "");
                quoteValue = quoteValue.Replace("…", "...");
                quoteValue = quoteValue.Replace("’", "'");
                quoteValue = quoteValue.Replace("&", "&");

                var quoteRegex = new Regex("(.*):(.*)");
                MatchCollection matches2 = quoteRegex.Matches(quoteValue);

                var character = "";
                var speech = "";

                if (matches2.Count == 0)
                {
                    speech = quoteValue;
                }
                else
                {
                    var quoteMatch = matches2[0];
                    if (quoteMatch.Groups[1].Value.Contains("<img") ||
                        quoteMatch.Groups[1].Value.Contains("<a href"))
                        break;

                    character = (quoteMatch.Groups[1].Value + "(").Split('(')[0].Trim();
                    speech = quoteMatch.Groups[2].Value;
                }

                episodeTranscript.Quotes.Add(new Quote()
                {
                    Id = episodeTranscript.Quotes.Count() + 1,
                    Season = episode.Season,
                    Number = episode.Number,
                    Image = string.Format(@"/Images/{0}.png", character),
                    Character = character,
                    Speech = speech,
                    CreatedOn = DateTime.Now
                });
            }

            onContentReady(episodeTranscript);
        },
        () =>
        {
            onConnectionFailed();
        },
        (percentage) =>
        {
            onProgressChanged(percentage);
        });
}

Saving the Episode Transcript Locally

We save the episode transcript much like we do with episode lists. The difference is that we persist an EpisodeTranscript instance. Another difference is that once an episode transcript is downloaded and persisted locally, it doesn't need to be downloaded again. This is why each episode has its own separated XML file.

public static void SaveEpisodeTranscript(EpisodeTranscript episodeTranscript)
{
    var episodeTranscriptFilePath = string.Format(EPISODETRANSCRIPT_FILE_PATH, 
                                           episodeTranscript.Season, episodeTranscript.Number);
    StreamResourceInfo streamResourceInfo = 
      Application.GetResourceStream(new Uri(episodeTranscriptFilePath, UriKind.Relative));

    using (IsolatedStorageFile isolatedStorage = IsolatedStorageFile.GetUserStoreForApplication())
    {
        string directoryName = System.IO.Path.GetDirectoryName(episodeTranscriptFilePath);
        if (!string.IsNullOrEmpty(directoryName) && 
                  !isolatedStorage.DirectoryExists(directoryName))
        {
            isolatedStorage.CreateDirectory(directoryName);
        }

        isolatedStorage.DeleteFile(episodeTranscriptFilePath);
        Serialize(isolatedStorage, episodeTranscriptFilePath, 
                  episodeTranscript, typeof(EpisodeTranscript));
    }
}

Searching the Episodes

This TV show is very popular in Brazil, and me and my friends love to discuss the situations, jokes, dialogues of it. Sometimes it is difficult to remember the season, the episode, and and who said what. Then it appeared obvious to me, that it would be nice for the app to have a search feature. It's obviously no big deal, just searching each episode for a particular text is relatively easy compared to what we have been doing so far. But indeed, it's a nice and fun feature.

Since the episodes are individually persisted in Isolated Storage, and since their contents are not indexed in any way, we don't have an easy way other than retrieving episode-by-episode and searching its contents. So, the more episodes we download, the longer it takes for the entire searching operation to complete. This is why we create a search function that reports its progress and remains responsive during the search process. If you're looking for a particular text and it's found in the third episode of the first season, it will quickly appear on the search result list, but the searching operation will keep going. Nevertheless, you'll be able to click that already found result and navigate to the respective episode before the search function finishes its job.

public static void SearchQuotes(string searchText, 
Action<int> onProgressChanged, 
Action<Quote> onNewQuotesFound, 
Action onSearchCompleted)
{
    searchText = searchText.ToLower();
    List<Quote> quotes = new List<Quote>();

    var episodes = EpisodeRepository.RetrieveEpisodeList();
    var episodeCount = episodes.Count();
    var currentEpisodePosition = 0;

    foreach (var episode in episodes)
    {
        currentEpisodePosition++;
        onProgressChanged((100 * currentEpisodePosition) / episodeCount);
        if (EpisodeRepository.EpisodeTranscriptExists(episode.Season, 
        episode.Number))
        {
            var episodeTranscript = EpisodeRepository
            .RetrieveEpisodeTranscript(episode.Season, episode.Number);

            var matches = episodeTranscript.Quotes.Where(x => 
            x.Character.ToLower().Contains(searchText.ToLower()) ||
                x.Speech.ToLower().Contains(searchText.ToLower()));

            foreach (var match in matches)
            {
                onNewQuotesFound(match);
            }
        }
    }
    onProgressChanged(100);
    onSearchCompleted();
}

Publishing to Facebook and Twitter

Reading the transcripts of your favorite TV show in your Windows Phone may be cool, but unless your family, friends, and workmates download the app, you'll have to share the bits with them by conversation. Fortunately, there is way to share it more effectively via social networks such as Facebook and Twitter, and we are going to make use of the native sharing tool that is provided with Windows Phone out of the box.

In order to share a specific quote, you must select and double-tap the speech balloon or group of balloons, and then you are redirected to the sharing page. Keep in mind that there may be restrictions to the maximum number of characters due to the nature of the social network to which you are posting.

Once you share, the selected quote will be posted along with a link to the app in the Windows Phone Marketplace.

private void EpisodeTranscriptListBox_DoubleTap(object sender, 
    System.Windows.Input.GestureEventArgs e)
{
    var selectedItems = EpisodeTranscriptListBox.SelectedItems;
    if (selectedItems != null)
    {
        ShareLinkTask shareLinkTask = new ShareLinkTask();
        shareLinkTask.LinkUri = 
        new Uri("http://windowsphone.com/s?appid=e28e5cab-a604-4913-af5c-5694923433b6", 
        UriKind.Absolute);
        shareLinkTask.Title = "The Big Bang Transcripts Viewer";
        StringBuilder sb = new StringBuilder();
        foreach (var selectedItem in selectedItems)
        {
            var quote = (Quote)selectedItem;
            sb.AppendFormat("{0}: {1}\r\n", quote.Character, quote.Speech.Trim());
        }
        shareLinkTask.Message = string.Format("{0}\r\n({1})", sb.ToString(), txtEpisodeName.Text);
        shareLinkTask.Show();
    }
}

Showing the Dialogues

The dialogues are the most important part of the application. They're the very reason for the app's existence. With that in mind, I tried to show them in a clean and fun way. The idea is that each quote has a separate colorized speech balloon, along with the photo of the character. Also, I thought it would be nice to have the dialogues in an alternate fashion, that is, alternating the speech direction from left to right, and then to left again, so that it would be similar to the experience in SMS and other mobile messaging applications.

That being said, the display of the dialogues is something that deserves to be discussed in some detail. First of all, we have the native and very powerful ListBox Silverlight control, which allows us to create templates for the items we want to list.

The template we have for the Listbox control has two basic behaviors: it can either show the series "comments" (like episode intro, credits sequence section, and so on) or the dialogues themselves. The comments are shown in a banner, while the dialogues are balloons accompanied by the character's photo. This differentiation makes the reading more fun and less confusing.

Image
Description
The IsComment property is converted to Visibility, so that if the line is a comment line, then the comment template will become visible. The comments content will be showed in a stylized, flag-like rectangle, in italics.
XAML
<Grid Visibility="{Binding IsComment, Converter={StaticResource 
    booleanToVisibilityConverter}}" Margin="10">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="40"/>
        <ColumnDefinition Width="360"/>
        <ColumnDefinition Width="40"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="2"/>
        <RowDefinition/>
        <RowDefinition Height="10"/>
    </Grid.RowDefinitions>
    <Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0" 
    Background="{Binding Id, Converter={StaticResource 
    quoteIdToBaloonBrushConverter}}" 
    CornerRadius="0,0,5,0" Margin="0,0,1,0"/>
    <Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="2" 
    Background="{Binding Id, Converter={StaticResource 
    quoteIdToBaloonBrushConverter}}" 
    CornerRadius="0,0,0,5" Margin="1,0,0,0"/>
    <Border Grid.Row="0" Grid.RowSpan="3" Grid.Column="0"
     Grid.ColumnSpan="3" BorderBrush="{StaticResource 
     PhoneBackgroundBrush}" 
     BorderThickness="1" Background="{Binding Id, 
     Converter={StaticResource 
     quoteIdToBaloonBrushConverter}}" 
     CornerRadius="10,10,0,0" Margin="10">
        <TextBlock Text="{Binding Speech}" FontSize="30" 
        FontStyle="Italic" TextWrapping="Wrap" Margin="4" 
        Foreground="{Binding Id, Converter={StaticResource 
        quoteIdToFontBrushConverter}}" ></TextBlock>
    </Border>
    <Path Width="40" Height="10" Grid.Column="0" 
    Grid.ColumnSpan="3" Grid.Row="0" 
    Grid.RowSpan="3" Fill="{StaticResource PhoneBackgroundBrush}" 
    Data="M0,0 L1,0 L1,1 L0,0" Stretch="Fill" 
    HorizontalAlignment="Left" VerticalAlignment="Bottom" 
    Margin="10,0,0,0"/>
    <Path Width="40" Height="10" Grid.Column="0" 
    Grid.ColumnSpan="3" Grid.Row="0" 
    Grid.RowSpan="3" Fill="{StaticResource PhoneBackgroundBrush}" 
    Data="M1,0 L0,0 L0,1 L1,0" Stretch="Fill" 
    HorizontalAlignment="Right" VerticalAlignment="Bottom" 
    Margin="0,0,10,0"/>
</Grid>


Image
Description
The IsSpeech property is converted to Visibility, so that if the line is a speech line, then the speech template will become visible. The speech content will be shown in a speech balloon, next to the character's picture. The quoteIdToPictureColumnConverter and quoteIdToBalloonColumnConverter converters take the quote's ID property and based on the parity value (that is, whether the ID is even or odd) they return the correct grid column value for the balloon speech rectangle and the character picture. Long story short, when the speech line is even, the balloon appears on the left side, and the character's picture on the right side.
XAML
<Grid HorizontalAlignment="Stretch" 
        Visibility="{Binding IsSpeech, Converter={StaticResource 
        booleanToVisibilityConverter}}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="100"/>
        <ColumnDefinition Width="250"/>
        <ColumnDefinition Width="100"/>
    </Grid.ColumnDefinitions>
    <StackPanel Grid.Column="{Binding Id, 
    Converter={StaticResource quoteIdToPictureColumnConverter}}" 
    VerticalAlignment="Center">
        <Image Source="{Binding Image}" Height="100" 
        Width="100"></Image>
        <TextBlock Text="{Binding Character}"/>
    </StackPanel>
    <Grid Grid.Column="{Binding Id, Converter={StaticResource quoteIdToBalloonColumnConverter}}" 
              Grid.ColumnSpan="2" Margin="5,15,5,15">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="20"/>
            <ColumnDefinition/>
            <ColumnDefinition Width="20"/>
        </Grid.ColumnDefinitions>
        <Path Grid.Column="0" Visibility="{Binding Id, 
          Converter={StaticResource evenQuoteIdToVisibilityConverter}}" 
          Fill="{Binding Id, Converter={StaticResource 
          quoteIdToBaloonBrushConverter}}" 
          Data="M0,1 L1,0 L1,2 L0,1" Height="20" 
          Width="20" Stretch="Fill"/>
        <Rectangle Grid.Column="1" Fill="{Binding Id, 
          Converter={StaticResource quoteIdToBaloonBrushConverter}}" 
          RadiusX="4" RadiusY="4"/>
        <TextBlock Grid.Column="1" Text="{Binding Speech}" 
          FontSize="26" 
          TextWrapping="Wrap" Margin="4" 
          Foreground="{Binding Id, 
          Converter={StaticResource 
          quoteIdToFontBrushConverter}}"></TextBlock>
        <Path Grid.Column="2" Visibility="{Binding Id, 
          Converter={StaticResource oddQuoteIdToVisibilityConverter}}" 
          Fill="{Binding Id, Converter={StaticResource 
          quoteIdToBaloonBrushConverter}}" 
          Data="M1,1 L0,0 L0,2 L1,1" Height="20" Width="20" 
          Stretch="Fill"/>
    </Grid>
</Grid>


Image
Description
In this case, the quoteIdToPictureColumnConverter and quoteIdToBalloonColumnConverter converters take the quote's ID property and based on the parity value (that is, whether the ID is even or odd) they return the correct grid column value for the balloon speech rectangle and the character picture. Based on this sample image, the balloon appears on the right side, and the character's picture on the left side.
XAML
<Grid HorizontalAlignment="Stretch" 
        Visibility="{Binding IsSpeech, Converter={StaticResource 
        booleanToVisibilityConverter}}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="100"/>
        <ColumnDefinition Width="250"/>
        <ColumnDefinition Width="100"/>
    </Grid.ColumnDefinitions>
    <StackPanel Grid.Column="{Binding Id, 
    Converter={StaticResource quoteIdToPictureColumnConverter}}" 
    VerticalAlignment="Center">
        <Image Source="{Binding Image}" Height="100" 
        Width="100"></Image>
        <TextBlock Text="{Binding Character}"/>
    </StackPanel>
    <Grid Grid.Column="{Binding Id, Converter={StaticResource 
    quoteIdToBalloonColumnConverter}}" 
    Grid.ColumnSpan="2" Margin="5,15,5,15">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="20"/>
            <ColumnDefinition/>
            <ColumnDefinition Width="20"/>
        </Grid.ColumnDefinitions>
        <Path Grid.Column="0" Visibility="{Binding Id, 
        Converter={StaticResource 
        evenQuoteIdToVisibilityConverter}}" 
        Fill="{Binding Id, Converter={StaticResource 
        quoteIdToBaloonBrushConverter}}" 
        Data="M0,1 L1,0 L1,2 L0,1" Height="20" 
        Width="20" Stretch="Fill"/>
        <Rectangle Grid.Column="1" Fill="{Binding Id, 
        Converter={StaticResource quoteIdToBaloonBrushConverter}}" 
        RadiusX="4" RadiusY="4"/>
        <TextBlock Grid.Column="1" Text="{Binding Speech}" 
        FontSize="26" 
        TextWrapping="Wrap" Margin="4" Foreground="{Binding Id, 
        Converter={StaticResource 
        quoteIdToFontBrushConverter}}"></TextBlock>
        <Path Grid.Column="2" Visibility="{Binding Id, 
        Converter={StaticResource oddQuoteIdToVisibilityConverter}}" 
        Fill="{Binding Id, Converter={StaticResource 
        quoteIdToBaloonBrushConverter}}" 
        Data="M1,1 L0,0 L0,2 L1,1" Height="20" Width="20" Stretch="Fill"/>
    </Grid>
</Grid>

Final Considerations

Thank you so much for your patience. I hope you have enjoyed the article and also hope you liked the concepts I presented here. If you would like to share complaints, questions, ideas, or suggestions, please feel free to fill in a post in the comments section below.

History

  • 2011-12-29: Initial version.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here