Advertisement
iOS SDK

iOS SDK: Working with Google Calendars

by

This tutorial will teach you how to build an app that will interact with the Google Calendar web service using OAuth 2.0. Read on!


Where We Left Off...

In my last tutorial, I showed you how to implement the OAuth 2.0 protocol in order to access Google services. By the end of that tutorial, we had built a fully functional class for accessing Google Services with OAuth 2.0. In this tutorial, I am going to put this class in action by implementing a demo app. Specifically, I am going to show you how to interact with the Google Calendar web service.


Project Overview

The app we'll build in this tutorial is going to let users get connected to their Google account, download their calendars, and create a new event with a description and a date/time. The new event will be posted to a calendar that the user selects.

Regarding our app structure, the basic view is going to be a table view that will contain three sections for setting the following data:

  • An event description
  • An event date/time
  • A target calendar

As far as the event description is concerned, a textfield will appear on the cell when the user edits the description and it will go away when he finishes doing so. For ease of use, an Input Accessory View will appear above the keyboard every time that the textfield is displayed.

For setting or changing the event date and time, another view (not a view controller) is going to be used. This view will contain a date picker view, from which the user will be able to pick a date. An event can be an all-day event, so no specific time needs to be set. For this case, a button will be used to set the date picker contents. For events occurring at a specific time, the date picker will display both date and time. For all-day events, only the date will be displayed. The view that contains the date picker (and a toolbar with the necessary bar button items as well) will be added as a subview to the view of our view controller when the user taps on the row of the second section of the table view.

Finally, the calendar section is going to be multi-functional. When the user is not yet signed into their Google account, only one row is going to exist in the section with a message that prompts the user to download their calendars. When the user selects this, the authorization process will be put in action. Once the access token has been obtained, an API call will be made to get the calendar list from Google. After the calendar list has been taken, the first calendar on the list is going to replace the prompting message on the row and become the selected calendar by default.

In addition to all of the above, a toolbar will exist under the table view and it will contain two bar button items. One button will be for posting the event on the selected calendar and a second button will be used to log out.

Let's get started!


1. Create a New Project

Step 1

Launch Xcode and create a new project. Select the Single View Application option and click Next:

gt6_1_project_template

In the Product Name field, add the GoogleCalendarPostDemo value. Of course, you may choose another name if you'd like. Also, make sure to check the Use Automatic Reference Counting option and uncheck everything else. After that, keep on going.

gt6_2_project_options

Finally, select a directory to store the project and click Create.

gt6_3_project_create

Step 2

Now you need to add the class files that were implemented in the previous tutorial. This class is going to be the mechanism that will do all the work behind the scenes. So, if you have the previous tutorial's project files, get these two:

  • GoogleOAuth.h
  • GoogleOAuth.m

Add them into your project. If you don't have the previous project, then you can download and get them from this post's download.

gt6_4_class_files

2. Building the Interface

We will use Interface Builder to setup the interface. As you will soon find out, several subviews are going to be added because we want to make the demo app as functional as it can be. Here is a series of steps that describe every subview you should add, along with the properties for each one:

  1. Open the ViewController.xib file to configure the interface and to add all the necessary subviews. First of all, set the view's Size to None in the Utilities Pane > Attributes Inspector > Simulated Metrics, in order to let the project work properly on iPhones prior to the 5.
    gt6_5_size_none
  2. Set the view's Background Color to White.
  3. Drag-and-drop a UIToolbar subview into the view. Place it at the bottom of the screen.
  4. Add the following items on the toolbar (ordered left-to-right):
    • Bar Button Item with Title: Sign out
    • Flexible Space Bar Button Item
    • Bar Button Item with Title: Post
  5. Add a UITableView subview on the view and let it occupy all the available space left on the view.
  6. Set the next two properties of the table view:
    • Style: Grouped
    • Background:: Clear Color

The following is what you should have at this point:

gt6_6_ib_sample

Next, we need to have another view that will contain the date picker view. Here are the steps:

  1. Add a new view outside of the default view and set its Size to None (like you did before). Also, set its Height to 460.
  2. Set the view's Background Color to Scroll View Textured Background Color.
  3. Add a UIDatePicker subview in the view and center it in accordance to the view's center.
  4. Add a UIToolBar subview at the bottom of the view.
  5. Add the following bar button items to the toolbar:
    • Bar Button Item with Title: Cancel
    • Flexible Space Bar Button Item
    • Bar Button Item with Title: All-day event
    • Flexible Space Bar Button Item
    • Bar Button Item with Title: Okay
gt6_7_ib_datepicker

Finally, add the following subviews outside the default view controller's view and in the second view we just created:

  1. A UIToolBar subview. This is going to be the Input Accessory View for the textfield that will be used to edit the event description. Add the next bar button items to it:
    • Bar Button Item with Title: Cancel
    • Flexible Space Bar Button Item
    • Bar Button Item with Title: Okay
  2. A UIActivityIndicatorView with the following properties:
    • Style: Large White
    • Background: Black Color
gt6_8_ib_iav
gt6_9_ib_activityindicator

3. Setup IBOutlet Properties & IBAction Methods

Step 1

We are going to need a few IBOutlet properties connected to our subviews, so we can modify them in code. To connect an IBOutlet property to a subview (as well as to create an IBAction method), you need to have the ViewController.h file shown in the Assistant Editor. So, click on the middle button of the Xcode Editor toolbar to let it show up. Make sure that the contents of the ViewController.h file are displayed there.

gt6_10_assistant_editor

I will show you how to create an IBOutlet property for the table view only. Use the same way to create the properties for the subviews I'll tell you about next.

Either on the Document Outline pane or directly on the view, do the following:

  1. Right-Click or Control-Click on the table view
  2. On the black popup menu, click on the circle next to the New Referencing Outlet option and keep the mouse button pressed.
  3. Drag-and-drop (and at the same time a blue line will follow your mouse) into the Assistant Editor window.
gt6_11_iboutlet_create

On the new window that appears, add the tblPostData as the Name of the property and don't touch any other options. Click on the Connect button or hit the Return button on your keyboard.

gt6_12_iboutlet_name

Here is a list with all the subviews that we need IBOutlet connections created, along with their names. Make sure to follow the same way as before and you'll be fine.

  • Post bar button item: barItemPost
  • Sign out bar button item: barItemRevokeAccess
  • Input Accessory View Toolbar (the alone toolbar): toolbarInputAccessoryView
  • View container of the date picker: viewDatePicker
  • Date Picker: dpDatePicker
  • All-day bar button item: barItemToggleDatePicker
  • Activity Indicator View: activityIndicatorView

Step 2

As you see, we added some bar button items in our views. We require from them to react on our taps, so we need to create IBAction methods to make that happen.

Creating an IBAction method is almost the same as I previously demonstrated. For the Post bar button item only, here is the procedure in detail:

  1. On either the Document Outline or directly on the view, right-click or control-click on the bar button item.
  2. On the black popup menu, under the Sent Actions section, click on the circle next to the Selector option and keep the mouse button down.
  3. Drag and drop on the Assistant Editor window.
gt6_13_ibaction_create

On the new window, add the post in the Name field and leave everything else as it is. Click on Connect or hit the Return button on your keyboard.

gt6_14_ibaction_name

Now, follow the same pattern and create the next IBAction methods for the given subviews:

  • Sign out bar button item: revokeAccess
  • Okay bar button item on the Input Accessory View Toolbar: acceptEditingEvent
  • Cancel bar button item on the Input Accessory View Toolbar: cancelEditingEvent
  • Okay bar button item on the Date Picker View Toolbar: acceptSelectedDate
  • Cancel bar button item on the Date Picker View Toolbar: cancelPickingDate
  • All-day bar button item on the Date Picker View Toolbar: toggleDatePicker

After the addition of all the IBOutlet properties and all the IBAction methods, your ViewController.h file should look like this:

@interface ViewController : UIViewController
@property (weak, nonatomic) IBOutlet UITableView *tblPostData;
@property (weak, nonatomic) IBOutlet UIBarButtonItem *barItemPost;
@property (weak, nonatomic) IBOutlet UIBarButtonItem *barItemRevokeAccess;
@property (strong, nonatomic) IBOutlet UIToolbar *toolbarInputAccessoryView;
@property (strong, nonatomic) IBOutlet UIView *viewDatePicker;
@property (weak, nonatomic) IBOutlet UIDatePicker *dpDatePicker;
@property (weak, nonatomic) IBOutlet UIBarButtonItem *barItemToggleDatePicker;
@property (strong, nonatomic) IBOutlet UIActivityIndicatorView *activityIndicatorView;


- (IBAction)post:(id)sender;
- (IBAction)revokeAccess:(id)sender;
- (IBAction)acceptEditingEvent:(id)sender;
- (IBAction)cancelEditingEvent:(id)sender;
- (IBAction)acceptSelectedDate:(id)sender;
- (IBAction)cancelPickingDate:(id)sender;
- (IBAction)toggleDatePicker:(id)sender;

@end

4. Adopting Protocols

Step 1

Now that the interface has been setup and configured and the IBOutlet properties along with the IBAction methods have been created and connected, it's time to start writing some code. Previously we added the GoogleOAuth header and implementation files in the project, but that's not enough to make our class work. We also need to import it in the view controller's class and adopt its protocol.

Open the ViewController.h file and import the GoogleOAuth.h file at the top of the file:

#import <UIKit/UIKit.h>
#import "GoogleOAuth.h"

Step 2

In addition to the GoogleOAuth class' protocol, we need to adopt the following as well:

  • UITableViewDelegate: the delegate of the table view.
  • UITableViewDatasource: the datasource of the table view.
  • UITextFieldDelegate: the delegate of the textfield that will be used to edit the event description.

So, while still within the ViewController.h file, modify the @interface statement like this:

@interface ViewController : UIViewController <UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate, GoogleOAuthDelegate>

5. Declaring Private Properties & Methods

Step 1

There are some properties and some methods that should be declared at the private section of the class and are required to make everything work smoothly. These properties are mostly going to store application information, but some simple flags will also be used to indicate program state.

The next code snippet presents all the private data members you should add in your project (copy and paste them if you'd like). The comments explain everything. Don't forget that we are working now in the ViewController.m file!

@interface ViewController ()

// The string that contains the event description.
// Its value is set every time the event description gets edited and its
// value is displayed on the table view.
@property (nonatomic, strong) NSString *strEvent;

// The string that contains the date of the event.
// This is the value that is displayed on the table view.
@property (nonatomic, strong) NSString *strEventDate;

// This string is composed right before posting the event on the calendar.
// It's actually the quick-add string and contains the date data as well.
@property (nonatomic, strong) NSString *strEventTextToPost;

// The selected event date from the date picker.
@property (nonatomic, strong) NSDate *dtEvent;

// The textfield that is appeared on the table view for editing the event description.
@property (nonatomic, strong) UITextField *txtEvent;

// This array is one of the most important properties, as it contains
// all the calendars as NSDictionary objects.
@property (nonatomic, strong) NSMutableArray *arrGoogleCalendars;

// This dictionary contains the currently selected calendar.
// It's the one that appears on the table view when the calendar list
// is collapsed.
@property (nonatomic, strong) NSDictionary *dictCurrentCalendar;

// A GoogleOAuth object that handles everything regarding the Google.
@property (nonatomic, strong) GoogleOAuth *googleOAuth;

// This flag indicates whether the event description is being edited or not.
@property (nonatomic) BOOL isEditingEvent;

// It indicates whether the event is a full-day one.
@property (nonatomic) BOOL isFullDayEvent;

// It simply indicates whether the calendar list is expanded or not on the table view.
@property (nonatomic) BOOL isCalendarListExpanded;

@end

Beyond that, add the next method declarations after the properties and before the @end statement:

-(void)setupEventTextfield;
-(NSString *)getStringFromDate:(NSDate *)date;
-(void)showOrHideActivityIndicatorView;

Step 2

After having declared all these properties, let's do some initialization. This will take place on the viewDidLoad: method. Note that in this method we also set self as the delegate and the datasource of the table view.

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // Set self as the delegate and datasource of the table view.
    [_tblPostData setDelegate:self];
    [_tblPostData setDataSource:self];
    
    // Set the initial values of the following private properties.
    _strEvent = @"";
    _strEventDate = @"Pick a date...";
    _isEditingEvent = NO;
    _isFullDayEvent = NO;
    _isCalendarListExpanded = NO;
    
    // Initialize the googleOAuth object.
    // Pay attention so as to initialize it with the initWithFrame: method, not just init.
    _googleOAuth = [[GoogleOAuth alloc] initWithFrame:self.view.frame];
    // Set self as the delegate.
    [_googleOAuth setGOAuthDelegate:self];
}

6. Table View Datasource & Delegate Methods

Let's keep going by writing some datasource and delegate methods related to the table view. For the time being, we'll add only some standard code. As we move on, we'll add even more code when it's required. So, let's go ahead. As I said at the beginning, there are going to be three sections on the table view:

-(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{
    return 3;
}

Each section is going to contain one row. For the third section, we'll have as many rows as the calendars support. In this method, you can see how the _isCalendarListExpanded flag is used for first time:

-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{    
    if (section != 2) {
        return 1;
    }
    else{
        // Depending on whether the calendars are listed in the table view,
        // the respective section will have either one row, or as many as the calendars are.
        if (!_isCalendarListExpanded) {
            return 1;
        }
        else{
            return [_arrGoogleCalendars count];
        }
    }
}

Let's add some footer titles, in order to make our demo app more descriptive. Nothing hard here:

-(NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
{
    // Set the footer title depending on the section value.
    NSString *footerTitle = @"";
    if (section == 0) {
        footerTitle = @"Event short description";
    }
    else if (section == 1){
        footerTitle = @"Event date";
    }
    else{
        footerTitle = @"Google Calendar";
    }
    
    return footerTitle;
}

Set the height of the row for each cell:

-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
    return 50.0;
}

Regarding the tableView:cellForRowAtIndexPath: datasource method, this one is going to be built step-by-step while we add more functionalities to the app. For now, let's write only the basics:

-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    static NSString *CellIdentifier = @"Cell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier: CellIdentifier];
    
    if (cell == nil) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
        
        [cell setSelectionStyle:UITableViewCellSelectionStyleGray];
        [cell setAccessoryType:UITableViewCellAccessoryNone];
     
        // Set a font for the cell textLabel.
        [[cell textLabel] setFont:[UIFont fontWithName:@"Trebuchet MS" size:15.0]];
    }
        
    return cell;
}

Finally, we have only one delegate method, the well-known tableView:didSelectRowAtIndexPath:. Just like the previous one, this is also going to be built step-by-step. For now, make it only remove the selection from each row that is tapped:

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    // At first, remove the selection from the tapped cell.
    [[_tblPostData cellForRowAtIndexPath:indexPath] setSelected:NO];
}

7. Editing the Event Description

Step 1

Until now, I have already said many times that a textfield is going to appear on the table view every time that we want to change the event description. However, when not editing, the textfield should not appear. After we finish editing, we'll update the event description and make the textfield go away.

Before we bring this behavior to life, it might be better to implement the private method that we have declared, the setupEventTextfield. In this method, we'll do the following tasks:

  • We will initialize the textfield by setting a frame related to the cell content view's frame and set a style too.
  • We'll set the contents of the strEvent string as its text.
  • Remember the (alone) toolbar we added in the Interface Builder earlier? We'll set it as the input accessory view of the textfield.
  • We'll set self as its delegate so we can handle the Return key of the keyboard.

Let's see it:

-(void)setupEventTextfield{
    // Initialize the textfield by setting the following properties.
    // Add or remove properties depending on your demand.
    if (!_txtEvent) {
        _txtEvent = [[UITextField alloc] initWithFrame:CGRectMake(10.0, 10.0,
                                                                  [[_tblPostData cellForRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]] contentView].frame.size.width - 20.0,
                                                                  30.0)];
        [_txtEvent setBorderStyle:UITextBorderStyleRoundedRect];
        [_txtEvent setText:_strEvent];
        [_txtEvent setInputAccessoryView:_toolbarInputAccessoryView];
        [_txtEvent setDelegate:self];
    }
}

Step 2

Now that this method is ready and we can call it any time we'd like to initialize the textfield, let's see how we can implement the behavior we expect from the table view. We want to display the textfield every time that we tap on the row of the first section, so let's do so.

Inside the tableView:didSelectRowAtIndexPath: we'll check if the event is currently being edited. If not, then we'll call the previously implemented method to initialize the textfield, we'll change the flag status indicating whether the event is being edited and we'll show the keyboard.

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    // At first, remove the selection from the tapped cell.
    [[_tblPostData cellForRowAtIndexPath:indexPath] setSelected:NO];
    
    if ([indexPath section] == 0) {
        // If the row of the first section is tapped, check whether the event description is being edited or not.
        // If not, then setup and show the textfield on the cell.
        if (!_isEditingEvent) {
            [self setupEventTextfield];
        }
        else{
            return;
        }
        
        // Change the value of the isEditingEvent flag.
        _isEditingEvent = !_isEditingEvent;
        // Reload the selected row.
        [_tblPostData reloadRowsAtIndexPaths:[NSArray arrayWithObject:indexPath]
                            withRowAnimation:UITableViewRowAnimationAutomatic];
        
        // If the textfield has been added as a subview to the cell,
        // then make it the first responder and show the keyboard.
        if (_isEditingEvent) {
            [_txtEvent becomeFirstResponder];
        }
    }    
}

Step 3

Now let's update the tableView:cellForRowAtIndexPath: so it reflects the state of the first section at any given time:

-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    ...
    ...
    ...
    if ([indexPath section] == 0) {
        if (!_isEditingEvent) {
            // If currently the event description is not being edited then just show
            // the value of the strEvent string and let the cell contain a disclosure indicator accessory view.
            // Also, set the gray as the selection style.
            [[cell textLabel] setText:_strEvent];
            [cell setAccessoryType:UITableViewCellAccessoryDisclosureIndicator];
            [cell setSelectionStyle:UITableViewCellSelectionStyleGray];
        }
        else{
            // If the event description is being edited, then empty the textLabel text so as to avoid
            // having text behind the textfield.
            // Add the textfield as a subview to the cell's content view and turn the selection style to none.
            [[cell textLabel] setText:@""];
            [[cell contentView] addSubview:_txtEvent];
            [cell setSelectionStyle:UITableViewCellSelectionStyleNone];
        }
    }
}

Great! If you run the app now, you'll notice that the textfield appears on the table view when you tap on the row of the first section. However, you cannot make the textfield vanish and keep the edited value. Why? Because we need to implement the related IBAction methods of the input accessory view bar button items.

Step 4

Let's begin with the acceptEditingEvent: IBAction method. In short, in this method we'll keep the typed event description on the strEvent string, we'll change the isEditingEvent flag's value, we'll make the keyboard disappear, and we'll refresh the table view. Here it is:

- (IBAction)acceptEditingEvent:(id)sender {
    // If the strEvent property is already initialized then set its value to nil
    // as it's going to be re-allocated right after.
    if (_strEvent) {
        _strEvent = nil;
    }
    
    // Keep the text entered in the textfield.
    _strEvent = [[NSString alloc] initWithString:[_txtEvent text]];

    // Indicate that no longer the event description is being edited.
    _isEditingEvent = NO;
    
    // Resign the first responder and make the textfield nil.
    [_txtEvent resignFirstResponder];
    [_txtEvent removeFromSuperview];
    _txtEvent = nil;

    // Reload the row of the first section of the table view.
    [_tblPostData reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:0 inSection:0]]
                        withRowAnimation:UITableViewRowAnimationAutomatic];
}

The cancelEditingEvent: is similar.

- (IBAction)cancelEditingEvent:(id)sender {
    // Indicate that no longer the event description is being edited.
    _isEditingEvent = NO;
    
    // Resign the first responder.
    [_txtEvent resignFirstResponder];
    [_txtEvent removeFromSuperview];
    _txtEvent = nil;
    
    // Reload the first row of the first section of the table view.
    [_tblPostData reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:0 inSection:0]]
                        withRowAnimation:UITableViewRowAnimationAutomatic];
}

Step 5

In order to be perfectly complete regarding the textfield and the event description editing in general, we have one more thing left to do. Namely, we must handle the Return button of the keyboard and make it work just like the acceptEditingEvent: IBAction method. For this reason, implement the following delegate method:

-(BOOL)textFieldShouldReturn:(UITextField *)textField{
    // In case the Return button on the keyboard is tapped, call the acceptEditingEvent: method
    // to handle it.
    [self acceptEditingEvent:nil];
    return YES;
}

As you can see, we simply make a call to the IBAction method and nothing more. Now, we are 100% complete regarding the event editing and the textfield manipulation.


8. Picking an Event Date

Step 1

One part of our demo app is complete. Let's go ahead now and let's fully implement the date picking feature. In this case, what we want is when we tap on the row of the second section on the table view, to show the view that contains the date picker and through it to select a date. After that, we want the picked date to be displayed on the table view as well. Special care should be given to the fact that an event can be set for a specific time, but it can also be an all-day event, which means that picking a time has no point at all. We'll see all this next.

Let's begin by showing the date picker container view to the self.view. It's just a matter of one line, which should be written in the tableView:didSelectRowAtIndexPath: delegate method, under any other content it has until now:

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    ...
    ...
    else if ([indexPath section] == 1){
        // If the row of the second section is tapped, just show the view that contains the date picker.
        [self.view addSubview:_viewDatePicker];
    }
}

Step 2

Okay, that was the easy part. If you run the app now and you tap on the row of the second section, the date picker container view will be displayed on the screen. You'll notice that by default, both date and time are represented in the picker. That's nice if we want to set a specific time for the event. However, we should not let the time appear on the date picker if we are talking about an all-day event. Therefore, we need to implement the toggleDatePicker: IBAction method.

The way this method is going to work is fairly simple. Depending on the current date picker's contents, we will set its mode and we'll also change the respective bar button item's title. Don't forget that there is a flag as well, the isFullDayEvent variable, that should be changed accordingly. The following is the implementation:

- (IBAction)toggleDatePicker:(id)sender {
    if ([_dpDatePicker datePickerMode] == UIDatePickerModeDateAndTime) {
        // If the date picker currently shows both date and time, then set it to show only date
        // and change the title of the barItemToggleDatePicker item.
        // In this case the user selects to make a full-day event.
        [_dpDatePicker setDatePickerMode:UIDatePickerModeDate];
        [_barItemToggleDatePicker setTitle:@"Specific time"];
    }
    else{
        // Otherwise, if only date is shown on the date picker, set it to show time too.
        // The event is no longer a full-day one.
        [_dpDatePicker setDatePickerMode:UIDatePickerModeDateAndTime];
        [_barItemToggleDatePicker setTitle:@"All-day event"];
    }
    
    // Change the flag that indicates whether is a full-day event or not.
    _isFullDayEvent = !_isFullDayEvent;
}

Give it another try now and tap (or click on the Simulator) on the All-day event bar button item. Notice how the contents of the date picker get changed, along with the title of the button.

Step 3

Now we know when an event is set as a full-day event, but we are still unable to keep the selected date and to make it appear on the table view. Similarly, we cannot yet cancel the date picking and go back to our view.

So, let's work on both of these IBAction methods now.

In the acceptSelectedDate: IBAction method we need to do only four things: (1) keep the selected date as a string, (2) store the date as a NSDate object, (3) remove the view from the superview, and, finally, (4) reload our table view in order to show the selected date.

- (IBAction)acceptSelectedDate:(id)sender 
{
    // Keep the selected date as a NSDate object.
    _dtEvent = [_dpDatePicker date];
    // Also, convert it to a string properly formatted depending on whether the event is a full-day one or not
    // by calling the getStringFromDate: method.
    _strEventDate = [[NSString alloc] initWithString:[self getStringFromDate:[_dpDatePicker date]]];
    
    // Remove the view with the date picker from the self.view.
    [_viewDatePicker removeFromSuperview];
    
    // Reload the row of the second section of the table view to reflect the selected date.
    [_tblPostData reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:0 inSection:1]]
                        withRowAnimation:UITableViewRowAnimationAutomatic];
}

In the cancelPickingDate: IBAction method, we only need to remove the date picker container view from the superview.

- (IBAction)cancelPickingDate:(id)sender {
    // Just remove the view with the date picker from the superview.
    [_viewDatePicker removeFromSuperview];
}

Step 4

In the acceptSelectedDate: method, we made a call to the getStringFromDate: private method, which is declared but not yet implemented. It's now time to work with this method. Before I present the code, I should make an observation.

The purpose of the getStringFromDate: method is to get the date we provide (the selected date in our example) and to return this date as a string and formatted the way we want. However, there are two kind of string formats we need to have, depending on whether the event is a full-day one or not. If the event date has a specific time, we want this time to be present on the string. This means that we'll have a condition in our method that will determine what the output string format will be. So, having made our intention clear, enter the following code:

-(NSString *)getStringFromDate:(NSDate *)date{
    // Create a NSDateFormatter object to handle the date.
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    
    if (!_isFullDayEvent) {
        // If it's not a full-day event, then set the date format in a way that contains the time too.
        [formatter setDateFormat:@"EEE, MMM dd, yyyy, HH:mm"];
    }
    else{
        // Otherwise keep just the date.
        [formatter setDateFormat:@"EEE, MMM dd, yyyy"];
    }
    
    // Return the formatted date as a string value.
    return [formatter stringFromDate:date];
}

The above will give us something like Mon, Aug 12, 2013, 17:32.

For more information about the date symbols used here and any other symbols that exist, look at the Date Format Patterns.

Note: Keep in mind that you should change the date symbol order in a real app in order to match the date representation of your own country.

Step 5

So far so good: just one thing left to do. Add some code on the tableView:cellForRowAtIndexPath: method so the selected date will be displayed. Under all of the other contents of the method, add the next snippet:

-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    ...
    ...
    ...
    else if ([indexPath section] == 1){
        // In the event date cell just show the strEventDate string which either prompts the user
        // to pick a date, or contains the selected date as a string.
        // Also, add a disclosure indicator view.
        [[cell textLabel] setText:_strEventDate];
        [cell setAccessoryType:UITableViewCellAccessoryDisclosureIndicator];
    }

    return cell;
}

9. App Authorization

If you have already run the app, or if you do it now, you'll notice that the row of the third section on the table view contains the Download calendars... message. Of course, nothing takes place when you tap on this method. What we'd like to do on tap is to make an API call to Google and request the information we need. But, prior to this, we must authorize ourselves against the Google service and obtain an access token that will be used to exchange data. Actually, all of this will be done by the GoogleOAuth class we included early on. All wee need to do is provide this class with the client ID, the client secret, and the scope. So, let's pay a visit to the Google developers website and get all the values we need.

Step 1

Go to the Google Developer website. Click on the Sign In button that exists on the top-right side of the webpage to login.

gt6_15_sign_in
gt6_16_sign_in_2

After you have signed in, scroll down on the page until you locate the API Console icon.

gt6_17_api_console

Click on it and you'll be transferred to your Dashboard, where you handle all of your projects. If you must, create a project now, otherwise select the project you created from the previous tutorial in this series. On the menu at the left side of the webpage, click on the API Access option.

gt6_18_api_access_option

Details about the current project will be displayed on the right side of the page. In there, you can track down the client ID and client secret values that you will need. Note them, and let's move ahead.

gt6_19_client_info

Next, we need to tell Google that we want to use the Calendars service. To do so, click on the Services option at the menu on the left side of the webpage. A list of all the provided services will appear. Locate the Calendar API item. Click on the Off button to enable the specific service for the current project.

gt6_20_services

Step 2

What we haven't located yet is the scope value for getting the calendar info we need. Just to remind you, a scope indicates an API that the app requests access for. The best practice to locate what you want is to use the search engine for the Google developer website. So, go to the homepage of the Google developer site and search for the term Google Calendar API. In the results page, click on the first result.

gt6_22_search_box

ake a look around if you want and explore using the menus. For this tutorial, you should go to Reference > Calendar List > list. A new webpage is loaded, containing information about the calendar list API call. Find the Authorization area near the bottom of the page. There you can find the scope we need: https://www.googleapis.com/auth/calendar.

gt6_21_scopes_calendarlist

Step 3

In the previous steps, I showed you how you can enable a service on Google and the best way to find what you need from the developer web site. Now, we can keep building the app.

Now that we have access to all the data we need from Google, let's get back to our app and see how to use the information. Append the following code to our project:

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
    ...
    ...
    ...
    else if ([indexPath section] == 2){
        if (_arrGoogleCalendars == nil || [_arrGoogleCalendars count] == 0) {
            // If the arrGoogleCalendars array is nil or contains nothing, then the calendars should be
            // downloaded from Google.
            // So, show the activity indicator view and authorize the user by calling the the next
            // method of our custom-made class.            
            [self showOrHideActivityIndicatorView];
            [_googleOAuth authorizeUserWithClienID:@"YOUR_CLIENT_ID"
                                   andClientSecret:@"YOUR_CLIENT_SECRET"
                                     andParentView:self.view
                                         andScopes:[NSArray arrayWithObject:@"https://www.googleapis.com/auth/calendar"]];
        }
}

You'll notice that above we display the indicator view as well. We do this because we don't know how long it'll take to obtain authorization. Don't forget to set your own values for the client ID and the client secret!

When you use the app for the first time, the embedded web view will appear. You must enter your credentials and sign into your Google account to allow the app to access your calendars. If everything goes okay, you'll be authorized without a problem.


10. Downloading Calendar Data

Step 1

At this point, we need to implement some of the GoogleOAuth delegate methods. Let's begin with the authorizationWasSuccessful method, where we'll handle a successful authorization by making the API call for getting the calendar list.

However, we need the API URL string if we want to proceed. Navigate to the proper calendar list page in your Google developer account. At the top of the page you'll find the URL along with the HTTP method that should be used.

gt6_23_api_calendarlist

Now we can implement the delegate method:

-(void)authorizationWasSuccessful{
    // If user authorization is successful, then make an API call to get the calendar list.
    // For more infomation about this API call, visit:
    // https://developers.google.com/google-apps/calendar/v3/reference/calendarList/list
    [_googleOAuth callAPI:@"https://www.googleapis.com/calendar/v3/users/me/calendarList"
           withHttpMethod:httpMethod_GET
       postParameterNames:nil
      postParameterValues:nil];
}

Step 2

If all works according to plan, the responseFromServiceWasReceived:andResponseJSONAsData: delegate method will be called by the GoogleOAuth class. We are responsible to check if Google responded with the desired results, and then to keep only the data that we care about.

Let's discuss a bit about what the response contains and how we are going to manage the data. What we should do first is to convert the response JSON data into an NSDictionary object. If you NSLog this dictionary, we'll see the way the returned data is formed. See the following example:

{
etag = "\"AaJWGrGt8CrZSonQa3iAA4QAo_s/oZDiRXBvAIXr3JkNwKQRZZfQzQ4\"";
    items =     (
                { … }
		{ … }
		{ … }
    );
    kind = "calendar#calendarList";
}

Inside each curly bracket there is a block containing a bunch of information regarding every calendar you have created in Google Calendars. The items object is equivalent to an array which contains dictionaries as objects. In other words, we will extract the items object as a NSArray and we'll handle every single object of it as a NSDictionary object.

Let's get back on track again. Once we acquire all the items as NSArray objects, we'll go through a loop to access each calendar's details and we'll keep only the values we want for the purposes of this example. Actually, we are going to create key-value pairs with these values with the goal of creating new NSDictionaries, which will be stored in the arrGoogleCalendars array. This array is the calendar list for our app. Also, the dictCurrentCalendar dictionary will be initialized with the contents of the first calendar from the list. Once this has been done, the Post and Logout items will become enabled. We'll also hide the activity indicator view and we'll refresh the table to show the selected calendar.

Here is the code:

-(void)responseFromServiceWasReceived:(NSString *)responseJSONAsString andResponseJSONAsData:(NSData *)responseJSONAsData{
    NSError *error;
    
    if ([responseJSONAsString rangeOfString:@"calendarList"].location != NSNotFound) {
        // If the response from Google contains the "calendarList" literal, then the calendar list
        // has been downloaded.
        
        // Get the JSON data as a dictionary.
        NSDictionary *calendarInfoDict = [NSJSONSerialization JSONObjectWithData:responseJSONAsData options:NSJSONReadingMutableContainers error:&error];
        
        if (error) {
            // This is the case that an error occured during converting JSON data to dictionary.
            // Simply log the error description.
            NSLog(@"%@", [error localizedDescription]);
        }
        else{
            // Get the calendars info as an array.
            NSArray *calendarsInfo = [calendarInfoDict objectForKey:@"items"];

            // If the arrGoogleCalendars array is nil then initialize it so to store each calendar as a NSDictionary object.
            if (_arrGoogleCalendars == nil) {
                _arrGoogleCalendars = [[NSMutableArray alloc] init];
            }
            
            // Make a loop and get the next data of each calendar.
            for (int i=0; i<[calendarsInfo count]; i++) {
                // Store each calendar in a temporary dictionary.
                NSDictionary *currentCalDict = [calendarsInfo objectAtIndex:i];
                                
                
                // Create an array which contains only the desired data.
                NSArray *values = [NSArray arrayWithObjects:[currentCalDict objectForKey:@"id"],
                                   [currentCalDict objectForKey:@"summary"],
                                   nil];
                // Create an array with keys regarding the values on the previous array.
                NSArray *keys = [NSArray arrayWithObjects:@"id", @"summary", nil];
                
                // Add key-value pairs in a dictionary and then add this dictionary into the arrGoogleCalendars array.
                [_arrGoogleCalendars addObject:
                 [[NSMutableDictionary alloc] initWithObjects:values forKeys:keys]];
            }
            
            // Set the first calendar as the selected one.
            _dictCurrentCalendar = [[NSDictionary alloc] initWithDictionary:[_arrGoogleCalendars objectAtIndex:0]];
            
            // Enable the post and the sign out bar button items.
            [_barItemPost setEnabled:YES];
            [_barItemRevokeAccess setEnabled:YES];
            
            // Stop the activity indicator view.
            [self showOrHideActivityIndicatorView];
            
            // Reload the table view section.
            [_tblPostData reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:0 inSection:2]]
                                withRowAnimation:UITableViewRowAnimationAutomatic];
        }
    }
}

Step 3

Now that we've done all the above, we want to be able to tap on a calendar name and have the full list expand, allowing us to select another calendar. This will be done in the tableView:didSelectRowAtIndexPath: method. Add the following code snippet:

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
        ...
        ...
        ...
        else{
            // In this case the calendars exist in the arrGoogleCalendars array.
            if (_isCalendarListExpanded) {
                // If the calendar list is shown on the table view, then the tapped one shoule become the selected calendar.
                // Re-initialize the dictCurrentCalendar dictionary so it contains the information regarding the selected one.
                _dictCurrentCalendar = nil;
                _dictCurrentCalendar = [[NSDictionary alloc] initWithDictionary:[_arrGoogleCalendars objectAtIndex:[indexPath row]]];
            }
            
            // Change the value of the isCalendarListExpanded which indicates whether only the selected calendar is shown, or the
            // whole list.
            _isCalendarListExpanded = !_isCalendarListExpanded;

            // Finally, reload the section.
            [_tblPostData reloadSections:[NSIndexSet indexSetWithIndex:2]
                        withRowAnimation:UITableViewRowAnimationAutomatic];
        }
    }
    
}

Step 4

Finally, we need to update the tableView:cellForRowAtIndexPath: method to display everything we've done. Append the following code:

-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
    ...
    ...
    ...
    else if ([indexPath section] == 2){
        // This is the case where either the selected calendar is shown, or a list with all of them.
        if (!_isCalendarListExpanded) {
            // If the calendar list is not expanded and only the selected calendar is shown,
            // then if the arrGoogleCalendars array is nil or it doesn't have any contents at all prompt
            // the user to download them now.
            // Otherwise show the summary (title) of the selected calendar along with a disclosure indicator.
            if (![_arrGoogleCalendars count] || [_arrGoogleCalendars count] == 0) {
                [[cell textLabel] setText:@"Download calendars..."];
            }
            else{
                [[cell textLabel] setText:[_dictCurrentCalendar objectForKey:@"summary"]];
            }
            
            [cell setAccessoryType:UITableViewCellAccessoryDisclosureIndicator];
        }
        else{
            // This is the case where all the calendars should be listed.
            // Note that each calendar is represented as a NSDictionary which is read from the
            // arrGoogleCalendars array.
            // If the calendar that is shown in the current cell is the already selected one,
            // then add the checkmark accessory type to the cell, otherwise set the accessory type to none.
            NSDictionary *tempDict = [_arrGoogleCalendars objectAtIndex:[indexPath row]];
            [[cell textLabel] setText:[tempDict objectForKey:@"summary"]];
            
            if ([tempDict isEqual:_dictCurrentCalendar]) {
                [cell setAccessoryType:UITableViewCellAccessoryCheckmark];
            }
            else{
                [cell setAccessoryType:UITableViewCellAccessoryNone];
            }
        }
    }
    
    return cell;
}

That's it. Go and give it a try. Watch your calendars on the Simulator and play around for a while by expanding the calendar list and selecting a calendar!


11. Posting Calendar Events

Step 1

Let's see how we'll manage to add an event to a selected calendar. The first thing we should do is make sure that the event description and the event date have been set. To verify this, we'll check their values and we'll show an alert if something is wrong.

Our work will now take place in the post: IBAction method.

- (IBAction)post:(id)sender {
    // Before posting the event, check if the event description is empty or a date has not been selected.
    if ([_strEvent isEqualToString:@""]) {
        UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@""
                                                        message:@"Please enter an event description."
                                                       delegate:self
                                              cancelButtonTitle:nil
                                              otherButtonTitles:@"Okay", nil];
        [alert show];
        return;
    }
    
    if ([_strEventDate isEqualToString:@"Pick a date..."]) {
        UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@""
                                                        message:@"Please select a date for the event."
                                                       delegate:self
                                              cancelButtonTitle:nil
                                              otherButtonTitles:@"Okay", nil];
        [alert show];
        return;
    }

    ...
}

You'll notice that we'll simply check if the event description string is equal to the empty string and if the event date string is equal to its initial value.

Step 2

As I have already said, what we actually do in this project is to implement the Quick-add feature that Google Calendar supports. However, we need to know the format for the event string because there are some rules that apply. For example, there are special ways that the event date and time should be appended at the end of the event description string, so Google will know the exact date/time of the event. Of course, if you have already used the online Quick-Add feature, then you surely know what I am talking about.

Thankfully, Google provides help and examples regarding this issue. We just have to visit the Quick Add documentation. Go and visit this website and familiarize your self with this feature.

In our case, if we have a full-day event, we simply have to add the date using slashes at the end of the event description string (for example, "This is an event 08/12/2013"). If we don't have a full-day event, then we will add the time too, using the "at" between the date and time (for example, "This is an event 08/12/2013 at 21:40").

In addition, we need to know the URL of the API we want to call. Just like we with the calendar list, we must find the quick-add related information in the Google Calendar API documentation. If you don't want to bother looking for it right now, this is where you can find it. We must search for the following information:

  • Request: This is the URL of the API we want to call as it appeared at the top of the page. Also notice that we need to use the POST HTTP method.
  • POST parameters: Here are the parameters we need to send with the POST method. There are only two mandatory params, the ID of the calendar on which we want to add the event and the event text (of course formatted with the date, as I indicated before).
  • Authorization: This is the scope that we should be authorizing. In this case, the scope is equal to the calendar list scope, so we don't need to care about it. However, if that was a different value, then we should include it in the scope array during authorization.

So, let's go back to our method to finish things. Update our code as follows:

- (IBAction)post:(id)sender {
    ...
    ...
    ... 
    // Create the URL string of API needed to quick-add the event into the Google calendar.
    // Note that we specify the id of the selected calendar.
    NSString *apiURLString = [NSString stringWithFormat:@"https://www.googleapis.com/calendar/v3/calendars/%@/events/quickAdd",
                              [_dictCurrentCalendar objectForKey:@"id"]];

    // Build the event text string, composed by the event description and the date (and time) that should happen.
    // Break the selected date into its components.
    NSDateComponents *dateComponents = [[NSDateComponents alloc] init];
    dateComponents = [[NSCalendar currentCalendar] components:NSDayCalendarUnit|NSMonthCalendarUnit|NSYearCalendarUnit|NSHourCalendarUnit|NSMinuteCalendarUnit
                                                     fromDate:_dtEvent];
    
    if (_isFullDayEvent) {
        // If a full-day event was selected (meaning without specific time), then add at the end of the string just the date.
        _strEventTextToPost = [NSString stringWithFormat:@"%@ %d/%d/%d", _strEvent, [dateComponents month], [dateComponents day], [dateComponents year]];
    }
    else{
        // Otherwise, append both the date and the time that the event should happen.
        _strEventTextToPost = [NSString stringWithFormat:@"%@ %d/%d/%d at %d.%d", _strEvent, [dateComponents month], [dateComponents day], [dateComponents year], [dateComponents hour], [dateComponents minute]];
    }

    // Show the activity indicator view.
    [self showOrHideActivityIndicatorView];
    
    // Call the API and post the event on the selected Google calendar.
    // Visit https://developers.google.com/google-apps/calendar/v3/reference/events/quickAdd for more information about the quick-add event API call.
    [_googleOAuth callAPI:apiURLString
           withHttpMethod:httpMethod_POST
       postParameterNames:[NSArray arrayWithObjects:@"calendarId", @"text", nil]
      postParameterValues:[NSArray arrayWithObjects:[_dictCurrentCalendar objectForKey:@"id"], _strEventTextToPost, nil]];
}

If you run the app now and you click on the Post button, the event will be posted. You can verify this if you check your calendar from a web browser. However, until we handle the response from Google, we are unable to know if the event was successfully added or not.

Step 3

Each time that an event is posted, Google creates a respective object which contains several properties and data. After a successful addition, Google responds with this data, so we can have any information we need regarding the newly created event. In our case, we won't do something extraordinary. We'll simply show an alert view that will contain the following values, so we are sure that the event was successfully posted.

  • ID: Each new event gets a unique ID value, which we are going to display on the alert view.
  • Created Date: The date that the event was created.
  • Summary: The event description itself.

Of course, in a real app, you must handle this event data in a different way. Always regard the app's needs.

We are going to work in the responseFromServiceWasReceived:andResponseJSONAsData: method again. Notice that the JSON data is converted again into an NSDictionary object.

-(void)responseFromServiceWasReceived:(NSString *)responseJSONAsString andResponseJSONAsData:(NSData *)responseJSONAsData{
    NSError *error;
    ...
    ...
    ...    
    else if ([responseJSONAsString rangeOfString:@"calendar#event"].location != NSNotFound){
        // If the Google response contains the "calendar#event" literal then the event has been added to the selected calendar
        // and Google returns data related to the new event.
        
        // Get the response JSON as a dictionary.
        NSDictionary *eventInfoDict = [NSJSONSerialization JSONObjectWithData:responseJSONAsData options:NSJSONReadingMutableContainers error:&error];
        
        if (error) {
            // This is the case that an error occured during converting JSON data to dictionary.
            // Simply log the error description.
            NSLog(@"%@", [error localizedDescription]);
            return;
        }

        // An alert view with some information regarding the just added event will be shown.
        // Keep only the information that will be shown to the alert view.
        // Look at the https://developers.google.com/google-apps/calendar/v3/reference/events#resource for a complete list of the
        // data fields that Google returns.
        NSString *eventID = [eventInfoDict objectForKey:@"id"];
        NSString *created = [eventInfoDict objectForKey:@"created"];
        NSString *summary = [eventInfoDict objectForKey:@"summary"];
        
        // Build the alert message.
        NSString *alertMessage = [NSString stringWithFormat:@"ID: %@\n\nCreated:%@\n\nSummary:%@", eventID, created, summary];
        
        // Stop the activity indicator view.
        [self showOrHideActivityIndicatorView];
        
        // Show the alert view.
        UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"New event"
                                                        message:alertMessage
                                                       delegate:self
                                              cancelButtonTitle:nil
                                              otherButtonTitles:@"Great", nil];
        [alert show];
    }
}

Now, every time you add an event an alert view containing the new event info is displayed.


12. Signing Out

Signing out of the Google account is an option that should always be provided to the users, even though it's recommended to keep them connected for faster access to online services. For our app, it's only a matter of one single line of code, which we'll add on the revokeAccess: IBAction method.

- (IBAction)revokeAccess:(id)sender {
    // Revoke the access token.
    [_googleOAuth revokeAccessToken];
}

The revokeAccessToken method will make a call to the accessTokenWasRevoked delegate method to inform our class that the access token was revoked. This is going to be implemented next.


13. Ancillary Methods

Step 1

Until now, we used only two delegate methods of the GoogleOAuth class, the authorizationWasSuccessful and the responseFromServiceWasReceived:andResponseJSONAsData:. There are three more of them that we should implement. We need a delegate method for handling the access revocation, another delegate for handling any error that may occur, and a final method for dealing with any error messages that may exist in Google responses.

Regarding the access revocation, we'll have to do only three things. First, we need to remove all calendars from the arrGoogleCalendars array. Next, we need to disable the Post and the Sign Out buttons. Finally, we need to reload the table view to keep it up to date.

-(void)accessTokenWasRevoked{
    // Remove all calendars from the array.
    [_arrGoogleCalendars removeAllObjects];
    _arrGoogleCalendars = nil;
    
    // Disable the post and sign out bar button items.
    [_barItemPost setEnabled:NO];
    [_barItemRevokeAccess setEnabled:NO];
    
    // Reload the Google calendars section.
    [_tblPostData reloadSections:[NSIndexSet indexSetWithIndex:2] withRowAnimation:UITableViewRowAnimationAutomatic];
}

For the next two error handling delegate methods, we won't do much. We'll simply log the error messages and nothing further. It's obvious that in a real app you would have to handle the errors in an appropriate way and figure out workarounds that handle unexpected situations. For now, here are our stub implementations:

-(void)errorOccuredWithShortDescription:(NSString *)errorShortDescription andErrorDetails:(NSString *)errorDetails{
    // Just log the error messages.
    NSLog(@"%@", errorShortDescription);
    NSLog(@"%@", errorDetails);
}

-(void)errorInResponseWithBody:(NSString *)errorMessage{
    // Just log the error message.
    NSLog(@"%@", errorMessage);
}

Step 2

During the project implementation, we made a few calls to the showOrHideActivityIndicatorView private method, which has been declared and called but not yet built. Let's deal with it now.

-(void)showOrHideActivityIndicatorView{
    // If the activity indicator view is not currently animating (spinning),
    // then set its view center equal to self view's center, add it as a subview and start animating.
    // Otherwise stop animating and remove it from the superview.
    if (![_activityIndicatorView isAnimating]) {
        [_activityIndicatorView setCenter:self.view.center];
        [self.view addSubview:_activityIndicatorView];
        [_activityIndicatorView startAnimating];
    }
    else{
        [_activityIndicatorView stopAnimating];
        [_activityIndicatorView removeFromSuperview];
    }

}

That was the last thing we had to do. Finally, our app is finished! Try out all the functionality supported.


Conclusion

In this tutorial I demonstrated how to work with Google Calendars using OAuth 2.0. While implementing a web service in-app is a lot of work, I hope you find the effort to have been worthwhile! Thanks for reading, and feel free to leave any questions or feedback in the comments below!

Related Posts