Assignment #6 Task #3

Please note, this blog entry is from a previous course. You might want to check out the current one.

The Itinerary tab must show a list of all the places where photos in the chosen Virtual Vacation have been taken (sorted with first-visited first). Clicking on a place will show all the photos in the Virtual Vacation taken in that place. The place name should be the one returned by getting the new FLICKR_PHOTO_PLACE_NAME key in the Flickr photo dictionaries you retrieve from the photosInPlace:maxResults: method. You will need to use the new FlickrFetcher code available with this assignment. Use only the place’s name (as returned the the FLICKR_PHOTO_PLACE_NAME key) to determine what the place is (i.e. ignore things like the Flickr place id).

In both storyboards add two additional table view controllers with two new subclasses. Set their cell types and reuse identifiers. The first one will be used for the places in the itinerary, the second one for its photos. Create a push segue from the itinerary cell of the vacation view controller to the first new table view controller, another push segue from its cell to the second new table view controller and – only for the iPhone storyboard – an additional segue from the cell of the second new table view controller to the photo view controller from the previous assignments. Name all segue identifiers accordingly.

Both new tables will be populated using Core Data, thus add the Core Data framework to the project (like we did for the MapKit framework in the last assignment) as well as the Core-Data-table-view-controller class provided by Stanford and make both table subclasses of the CoreDataTableViewController class.

The first new table view controller – the itinerary table view controller – has as model the name of the vacation for which it has to display its places. Its setter can be used to set the title of the view:

// ItineraryTableViewController.h
@property (nonatomic, strong) NSString *vacation;

//  ItineraryTableViewController.m
@synthesize vacation = _vacation;

- (void)setVacation:(NSString *)vacation
{
    [VacationHelper sharedVacation:@"itinerary"];
    if (vacation == _vacation) return;
    _vacation = vacation;
    self.title = [@"Itinerary of " stringByAppendingString:vacation];
}

Its value is set in the vacation-table-view-controller class:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    [super prepareForSegue:segue sender:sender];
    if ([segue.identifier isEqualToString:@"Show Itinerary"]) {
        [segue.destinationViewController setVacation:self.vacation];        
    }
}

The places will be provided via Core Data. Its data model is created using the a Visual Map via File -> New -> File… -> Core Data -> Data Model. Add a new entity “Place” and to it two attributes: a string “name” to hold the name of the place and a date “firstVisited” to know when it was first visited. Then use Editor -> Create NSManagedObject Subclass… to create a subclass for the entity.

The table will be filled by the Core Data automatically. But to know how that should happen it needs to know what we want to display. Which is basically objects of the type “Place”, sorted by the date of their first visit:

- (void)setupFetchedResultsController
{
    NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Place"];
    request.sortDescriptors = [NSArray arrayWithObject:
                               [NSSortDescriptor sortDescriptorWithKey:@"firstVisited" 
                                                             ascending:YES]];
    self.fetchedResultsController = [[NSFetchedResultsController alloc] 
                                     initWithFetchRequest:request 
                                     managedObjectContext:[VacationHelper 
                                      sharedVacation:self.vacation].database.managedObjectContext 
                                       sectionNameKeyPath:nil 
                                                cacheName:nil];
}

To get the context of the database we use again the VacationHelper. When Core Data is changed it will update the table automatically, but only if the change occurred in the same instance of that database. If the change occurs in another instance, the current one would have to reload to notice it. Thus the VacationHelper has to make sure we use the same instance in the whole application by using a singleton pattern. When called the first time the following method will create an instance of the class, every other time it will use that same instance. In addition the method makes sure to close the old database if we choose to use another one:

+ (VacationHelper *)sharedVacation:(NSString *)vacationName
{
    static dispatch_once_t pred = 0;
    __strong static VacationHelper *_sharedVacation = nil;
    dispatch_once(&pred, ^{
        _sharedVacation = [[self alloc] init];
    });
    if (vacationName && ![vacationName isEqualToString:_sharedVacation.vacation]) {
        if (_sharedVacation.vacation) 
            [_sharedVacation.database closeWithCompletionHandler:^(BOOL success) {
                if (success)
                    _sharedVacation.database = nil;
                else 
                    NSLog(@"error in sharedVacation closing database");
        }];        
        _sharedVacation.vacation = vacationName;
    }
    return _sharedVacation;
}

The database itself might not be necessary to be instantiated when the VacationHelper is accessed. Thus we use laze instantiation for the database:

- (UIManagedDocument *)database
{
    if (!_database) {
        _database = [[UIManagedDocument alloc] initWithFileURL:
                     [self.baseDir URLByAppendingPathComponent:self.vacation]];
    }
    return _database;
}

However that might cause a problem where you change to another database and try access the database before it actually closed. In that cases you might want to add features to prevent such cases.

Also note we introduced a property to provide the base directory of where the database should be located to simplify the code, which generated the necessary URL in its getter. It uses another property which provides a reusable file manager:

- (NSURL *)baseDir {
    if (!_baseDir) {
        NSFileManager *fm = self.fileManager;
        _baseDir = [[[fm URLsForDirectory:NSDocumentDirectory 
                                inDomains:NSUserDomainMask] lastObject] 
                    URLByAppendingPathComponent:DEFAULT_DATABASE_FOLDER 
                                    isDirectory:YES];
        BOOL isDir = NO;
        NSError *error;
        if (![fm fileExistsAtPath:[_baseDir path] 
                      isDirectory:&isDir] || !isDir)
            [fm createDirectoryAtURL:_baseDir 
         withIntermediateDirectories:YES 
                          attributes:nil 
                               error:&error];
        if (error) return nil;        
    }
    return _baseDir;
}

- (NSFileManager *)fileManager
{
    if (!_fileManager) _fileManager = [[NSFileManager alloc] init];
    return _fileManager;
}

Back to the setup of the table, we setup the fetched results controller using another VacationHelper method:

- (void)viewDidLoad
{
    [super viewDidLoad];
    [VacationHelper openVacation:self.vacation usingBlock:^(BOOL success) {
        [self setupFetchedResultsController];
    }];
}

The helper method checks for the state of the database, and accordingly creates the database, opens or just uses the database:

+ (void)openVacation:(NSString *)vacationName usingBlock:(void (^)(BOOL))block
{
    VacationHelper *vh = [VacationHelper sharedVacation:vacationName];
    if (![vh.fileManager fileExistsAtPath:[vh.database.fileURL path]]) {
        [vh.database saveToURL:vh.database.fileURL 
              forSaveOperation:UIDocumentSaveForCreating 
             completionHandler:block];
    } else if (vh.database.documentState == UIDocumentStateClosed) {
        [vh.database openWithCompletionHandler:block];
    } else {
        BOOL success = YES;
        block(success);
    }
}

The cell views use the fetched results controller and format them.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Itinerary Cell";    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    Place *place = [self.fetchedResultsController objectAtIndexPath:indexPath];
    cell.textLabel.text = [FlickrData titleOfPlace:place];
    cell.detailTextLabel.text = [FlickrData subtitleOfPlace:place];;
    return cell;
}

Because the used class methods used an NSDictionary attribute and now the attribute is of the type Place, those helper methods need a slight modification, checking for the object type:

+ (NSString *)titleOfPlace:(id)place
{
    NSString *name;
    if ([place isKindOfClass:[NSDictionary class]])
        name = [place objectForKey:FLICKR_PLACE_NAME];
    else if ([place isKindOfClass:[Place class]])
        name = ((Place *)place).name;
    return [[name componentsSeparatedByString:@", "] objectAtIndex:0];
}

+ (NSString *)subtitleOfPlace:(id)place
{
    NSString *name;
    if ([place isKindOfClass:[NSDictionary class]])
        name = [place objectForKey:FLICKR_PLACE_NAME];
    else if ([place isKindOfClass:[Place class]])
        name = ((Place *)place).name;
    NSArray *nameParts = [name componentsSeparatedByString:@", "];
    NSRange range;
    range.location = 1;
    range.length = [nameParts count] - 1;
    return [[nameParts subarrayWithRange:range] componentsJoinedByString:@", "];
}

To see if anything of the above is actually working – beside looking at an empty table – we create a test database. To fill it with data we use recentGeoreferencedPhotos from FlickrFetcher which returns a number of photos which we enter into the database. One minor drawback is that the data returned from Flickr holds only an ID for the place and not the actual name. That’s why we use the ID as name, for test purposes.

+ (void)createTestDatabase {    
    VacationHelper *vh = [VacationHelper sharedVacation:DEBUG_VACATION_NAME];
    if ([vh.fileManager fileExistsAtPath:[vh.database.fileURL path]]) {
        vh.database = nil;
        return;
    }        
    NSArray *photos = [FlickrFetcher recentGeoreferencedPhotos];
    [VacationHelper openVacation:DEBUG_VACATION_NAME usingBlock:^(BOOL success) {
        for (NSMutableDictionary *photo in photos) {
            [photo setObject:[photo objectForKey:@"place_id"] forKey:FLICKR_PHOTO_PLACE_NAME];
            [Photo placeFromFlickrInfo:photo 
                inManagedObjectContext:vh.database.managedObjectContext];
        }
        [vh.database saveToURL:vh.database.fileURL 
              forSaveOperation:UIDocumentSaveForOverwriting 
             completionHandler:NULL];
    }];
}

The method above populates the Place entity, which is not required for this task but helps testing. To do that create a category of the NSManagedObject sub class Place via New -> File -> File… -> Objective-C category. To create a place from the Flickr data, we first check if the place already exists. If it does not, we fill the attributes of the object with the Flickr data:

+ (Place *)placeFromFlickrInfo:(NSDictionary *)flickrInfo 
        inManagedObjectContext:(NSManagedObjectContext *)context
{
    Place *place;    
    NSString *name = [flickrInfo objectForKey:FLICKR_PHOTO_PLACE_NAME];
    NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Place"];
    request.predicate = [NSPredicate predicateWithFormat:@"name = %@", name];
    request.sortDescriptors = [NSArray arrayWithObject:
                               [NSSortDescriptor sortDescriptorWithKey:@"name" 
                                                             ascending:YES]];
    NSError *error;
    NSArray *matches = [context executeFetchRequest:request error:&error];
    
    if (!matches || ([matches count] > 1)) {
        NSLog(@"Error in placeFromPhoto: %@ %@", matches, error);
    } else if (![matches count]) {
        place = [NSEntityDescription insertNewObjectForEntityForName:@"Place" 
                                              inManagedObjectContext:context];
        place.name = name;
        place.firstVisited = [NSDate date];        
    } else {
        place = [matches lastObject];
    }    
    return place;
}

The second new table view controller – the vacation-photo table view controller – has as model the name of the vacation as well as the place chosen in the previous table. Its setter can be used to set the title of the view:

// VacationPhotosTableViewController.h
@property (nonatomic, strong) NSString *vacation;
@property (nonatomic, strong) Place *place;

// VacationPhotosTableViewController.m
@synthesize vacation = _vacation;
@synthesize place = _place;

- (void)setPlace:(Place *)place
{
    if (_place == place) return;
    _place = place;
    self.title = [FlickrData titleOfPlace:place];
}

Their values are set in the itinerary-table-view-controller class:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    [super prepareForSegue:segue sender:sender];
    if ([segue.identifier isEqualToString:@"Show Vacation Photos"]) {
        [segue.destinationViewController setVacation:self.vacation];        
        NSIndexPath *indexPath = [self.tableView indexPathForCell:sender];
        Place *place = [self.fetchedResultsController objectAtIndexPath:indexPath];
        [segue.destinationViewController performSelector:@selector(setPlace:) withObject:place];
    }
}

The photos – like before the places – are provided via Core Data. Using the Visual Map add a new entity “Photo” and add four string attributes: “title” and “subtitle” to hold the title and description of the photo, “unique” for its ID, and “imageUrl” for its remote location. In addition add a relationship between the Photo and the Place entities. For the Photo entity call it “place”, for the Place entity call it “photos” and change it to a “To-Many relationship”. Select both entities and create new NSMangagedObject subclasses by replacing the previous one.

The fetched-results controller looks slightly different as the results have to be sorted case-insensitive by title, and are limited to photos located at the current place:

- (void)setupFetchedResultsController
{
    NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Photo"];
    request.sortDescriptors = [NSArray arrayWithObject:
                               [NSSortDescriptor sortDescriptorWithKey:@"title" 
                                                             ascending:YES 
                                                              selector:
                                @selector(localizedCaseInsensitiveCompare:)]];    
    request.predicate = [NSPredicate predicateWithFormat:@"place.name = %@", self.place.name];    
    self.fetchedResultsController = [[NSFetchedResultsController alloc] 
                                     initWithFetchRequest:request 
                                     managedObjectContext:self.place.managedObjectContext 
                                       sectionNameKeyPath:nil 
                                                cacheName:nil];
}

Note, that we can use the context of the current place to access the database.

Setting the fetched-results controller and using it to populated the cells similar to the methods above:

- (void)viewDidLoad
{
    [super viewDidLoad];
    [VacationHelper openVacation:self.vacation usingBlock:^(BOOL success) {
        [self setupFetchedResultsController];
    }];
}

- (UITableViewCell *)tableView:(UITableView *)tableView 
         cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Vacation Photo Cell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    Photo *photo = [self.fetchedResultsController objectAtIndexPath:indexPath];
    cell.textLabel.text = [FlickrData titleOfPhoto:photo];
    cell.detailTextLabel.text = [FlickrData subtitleOfPhoto:photo];
    return cell;
}

Like before the helper methods need adjustment for the different attribute types:

+ (NSString *)titleOfPhoto:(id)photo
{
    NSString *title;
    if ([photo isKindOfClass:[NSDictionary class]])
        title = [photo objectForKey:FLICKR_PHOTO_TITLE];
    else if ([photo isKindOfClass:[Photo class]])
        title = ((Photo *)photo).title;
    if ([title length]) return title;
    if ([photo isKindOfClass:[NSDictionary class]])
        title = [photo valueForKeyPath:FLICKR_PHOTO_DESCRIPTION];
    else if ([photo isKindOfClass:[Photo class]])
        title = ((Photo *)photo).subtitle;
    if ([title length]) return title;
    return UNKNOWN_PHOTO_TITLE;
}

+ (NSString *)subtitleOfPhoto:(id)photo
{
    NSString *title = [FlickrData titleOfPhoto:photo];
    if ([title isEqualToString:UNKNOWN_PHOTO_TITLE]) return @"";
    NSString *subtitle;
    if ([photo isKindOfClass:[NSDictionary class]])
        subtitle = [photo valueForKeyPath:FLICKR_PHOTO_DESCRIPTION];
    else if ([photo isKindOfClass:[Photo class]])
        subtitle = ((Photo *)photo).subtitle;
    if ([title isEqualToString:subtitle]) return @"";
    return subtitle;
}

As the previous test database does not include the photos yet, the creation of it has to be changed by replacing placeFromFlickrInfo: with photoFromFlickrInfo:

            [Photo placeFromFlickrInfo:photo
                inManagedObjectContext:vh.database.managedObjectContext];

Like before add a category for the Photos class, to generate the photo data for the database:

+ (Photo *)photoFromFlickrInfo:(NSDictionary *)flickrInfo 
        inManagedObjectContext:(NSManagedObjectContext *)context
{
    Photo *photo;
    NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Photo"];
    request.predicate = [NSPredicate predicateWithFormat:@"unique = %@", 
                         [flickrInfo objectForKey:FLICKR_PHOTO_ID]];
    request.sortDescriptors = [NSArray arrayWithObject:
                               [NSSortDescriptor sortDescriptorWithKey:@"title" 
                                                             ascending:YES]];
    NSError *error;
    NSArray *matches = [context executeFetchRequest:request error:&error];
    if (!matches || ([matches count] > 1 || error)) {
        NSLog(@"Error in photoFromFlickrInfo: %@ %@", matches, error);
    } else if ([matches count] == 0) {
        photo = [NSEntityDescription insertNewObjectForEntityForName:@"Photo" 
                                              inManagedObjectContext:context];
        photo.title = [flickrInfo objectForKey:FLICKR_PHOTO_TITLE];
        photo.subtitle = [flickrInfo valueForKeyPath:FLICKR_PHOTO_DESCRIPTION];
        photo.imageUrl = [[FlickrFetcher urlForPhoto:flickrInfo 
                                              format:FlickrPhotoFormatLarge] 
                          absoluteString];
        photo.unique = [flickrInfo objectForKey:FLICKR_PHOTO_ID];
        photo.place = [Place placeFromFlickrInfo:flickrInfo 
                          inManagedObjectContext:context];
    } else {
        photo = [matches lastObject];
    }
    return photo;
}

It is very important to delete the old database if you change your Core Data model, otherwise the application will crash.

Once a photo is selected we set the photo for the iPhone using the segue and for the iPad using the split view controller.

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    [super prepareForSegue:segue sender:sender];
    if ([segue.identifier isEqualToString:@"Show Photo"]) {        
        NSIndexPath *indexPath = [self.tableView indexPathForCell:sender];
        Photo *photo = [self.fetchedResultsController objectAtIndexPath:indexPath];
        [segue.destinationViewController performSelector:@selector(setCoreDataPhoto:) 
                                              withObject:photo];
    }
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    id vc = [self.splitViewController.viewControllers lastObject];
    if ([vc respondsToSelector:@selector(setCoreDataPhoto:)]) {
        Photo *photo = [self.fetchedResultsController objectAtIndexPath:indexPath];
        [vc performSelector:@selector(setCoreDataPhoto:) withObject:photo];
    }
}

Because the current photo view controller has as model a photo property which holds Flickr data, but we do not actually store that data in the database we provide add a new property to it coreDataPhoto to hold the Photo entity. Its setter makes sure to remove Flickr data from a previous photo should there be any (and the setter of the “normal” photo does so vice versa with the coreDataPhoto):

// PhotoViewController.h
@property (nonatomic, strong) Photo *coreDataPhoto;

// PhotoViewController.m
@synthesize coreDataPhoto = _coreDataPhoto;

- (void)setCoreDataPhoto:(Photo *)coreDataPhoto
{
    if (_coreDataPhoto == coreDataPhoto) return;
    _coreDataPhoto = coreDataPhoto;
    self.photo = nil;
    
    if (self.imageView.window) [self loadPhoto];    
}

To be able with this other photo data type loadPhoto needs adjustment as well. Mostly we use the photo ID to check if the photo to be displayed is the one we actually want to.

- (void)loadPhoto
{
    NSString *title = @"Photo";
    NSString *photoID;
    if (self.photo) {
        title = [FlickrData titleOfPhoto:self.photo];
        photoID = [[self.photo objectForKey:FLICKR_PHOTO_ID] copy];
    }
    if (self.coreDataPhoto) {
        title = [FlickrData titleOfPhoto:self.coreDataPhoto];   
        photoID = [self.coreDataPhoto.unique copy];
    }
....
    if (self.photo || self.coreDataPhoto) [self.spinner startAnimating];
....
        NSURL *url = [cache urlForCachedPhotoID:photoID format:FlickrPhotoFormatLarge];
        if (!url) {
            if (self.photo) {
                url = [FlickrFetcher urlForPhoto:self.photo 
                                          format:FlickrPhotoFormatLarge];
            }
            if (self.coreDataPhoto) {
                url = [NSURL URLWithString:self.coreDataPhoto.imageUrl];
            }            
        }
        NSData *data = [NSData dataWithContentsOfURL:url];
        [cache cacheData:data ofPhotoID:photoID format:FlickrPhotoFormatLarge];
        if (self.imageView.window 
            && ((self.photo 
                 && [[self.photo objectForKey:FLICKR_PHOTO_ID] isEqualToString:photoID]) 
             || (self.coreDataPhoto 
                 && [self.coreDataPhoto.unique isEqualToString:photoID]))) 
....
}

… and as the code above shows we use slightly changed caching methods using the photo IDs as well:

- (NSURL *)urlForLocalPhoto:(id)photo format:(FlickrPhotoFormat)format
{
    if (!photo) return nil;
    NSString * photoID;
    if ([photo isKindOfClass:[NSDictionary class]])
        photoID = [photo objectForKey:FLICKR_PHOTO_ID];
    if ([photo isKindOfClass:[Photo class]])
        photoID = ((Photo *)photo).unique;    
    return [self urlForCachedPhotoID:photoID format:format];
}

- (NSURL *)urlForLocalPhotoID:(NSString *)photoID format:(FlickrPhotoFormat)format
....

- (NSURL *)urlForCachedPhotoID:(NSString *)photoID format:(FlickrPhotoFormat)format
....

The complete code for this task is available at github.

FacebooktwitterredditpinterestlinkedintumblrmailFacebooktwitterredditpinterestlinkedintumblrmail

Leave a Reply

Your email address will not be published.