Expenses: A Core Data Tutorial part 3

User Preferences

So it has taken far longer than I anticipated to get this one out, but I suppose these things happen. This time we will be setting up some user defaults, we will be doing far less with Core Data specific stuff but a lot of this stills ties into Core Data.

Preferences we will add:

  • When we launch should we open a blank document, nothing, or the last edited document.
  • How far from the current date should expenses default to.
  • Are there any categories the user would like added to all new documents.

If you skipped the first two tutorials and would rather not do them here is a link to the project as of the end of part 2.

Expenses Part 2

We will be making a few new files today, if you want to create them all at once here is the list:

  • externs.h (Empty File)
  • AppController.h &.m (Objective-C class)
  • PreferenceController.h & .m (Objective-C NSWindowController subclass)
  • Preference.xib (Window XIB)

I am not going to go into huge detail about how NSUserDefaults works since numerous others have done an excellent job, including my two favorite places, CocoaDevCentral and Aaron’s book, Cocoa Programming for Mac OS X . We are going to go a bit beyond what is currently covered in either of those places though.

The externs file

So whenever I am going to have lots of externs I like to put them all in one file so they are easy to find when I forget what they are called. Then I just have to import them into each class file to use them. We will define several externs that will be the keys to our user defaults dictionary.

Create a new file that is a blank file, and name it “externs.h” then make it look like this:

 /* 
    externs.h 
    This is just a collection of all externs for the project. 
  */ 

 extern NSString *const ExpEmptyDocKey;           // To determine if we should oopen a blank document at launch
 extern NSString *const ExpOpenLastDocKey;        // To determine if we should open the last opened document at launch
 extern NSString *const ExpDefaultCategoriesKey;  // To determine if we should insert the default categories to new docs
 extern NSString *const ExpDefaultCategoriesList; // The list of what categories should get added if above is YES
 extern NSString *const ExpDefaultDateOffset;     // How far before the current date should expenses start at.

Don’t forget to save!

AppController Part 1

Now we need to set the ‘factory’ defaults. To do this we need to edit the AppController class a bit. If you have not already created the files do so now. Then, in AppController.h add the following above @interface:

 #import "Externs.h"  

 NSString *const ExpEmptyDocKey           = @"EmptyDocumentFlag"   ; 
 NSString *const ExpOpenLastDocKey        = @"OpenLastDocFLag"   ; 
 NSString *const ExpDefaultCategoriesKey  = @"DefaultCategoriesFlag"   ; 
 NSString *const ExpDefaultCategoriesList = @"DefaultCategoriesList"  ; 
 NSString *const ExpDefaultDateOffset     = @"DefaultDateOffset"  ; 

After that, in AppController.m add this code: (I’m not really happy with all of this code, but I haven’t found a better way to deal with the KVC issues in IB without using dictionaries)

 @implementation   AppController 
 + (  void  ) initialize { 
	// create a dictionary for the 'factory' defaults 
	NSMutableDictionary    *defaultValues = [   NSMutableDictionary      dictionary  ]; 
     
	// First to create an array of default Categories (for now we will just make a few) 
	NSMutableDictionary *catOne   = [[[NSMutableDictionary alloc] initWithCapacity: 1] autorelease]; 
	NSMutableDictionary *catTwo   = [[[NSMutableDictionary alloc] initWithCapacity: 1] autorelease]; 
	NSMutableDictionary *catThree = [[[NSMutableDictionary alloc] initWithCapacity: 1] autorelease]; 
	NSMutableDictionary *catFour  = [[[NSMutableDictionary alloc] initWithCapacity: 1] autorelease]; 
	NSMutableDictionary *catFive  = [[[NSMutableDictionary alloc] initWithCapacity: 1] autorelease]; 
	[catOne setValue: @"Housing" forKey: @"theString"];  
	[catTwo setValue: @"Food" forKey: @"theString"];  
	[catThree setValue: @"Entertainment" forKey: @"theString"]; 
	[catFour setValue: @"Misc" forKey: @"theString"]; 
	[catFive setValue: @"Transportation" forKey: @"theString"]; 
       
	NSArray *catArray = [NSArray arrayWithObjects: catOne, catTwo, catThree, catFour, catFive, nil];  
       
	// add defaults to dictionary 
	[defaultValues setObject: [NSNumber numberWithBool: YES] forKey: ExpEmptyDocKey]; 
	[defaultValues setObject: [NSNumber numberWithBool: YES] forKey: ExpDefaultCategoriesKey]; 
	[defaultValues setObject: [NSNumber numberWithBool: NO] forKey: ExpOpenLastDocKey]; 
	[defaultValues setObject: [NSNumber numberWithInt: 06] forKey: ExpDefaultDateOffset]; 
	[defaultValues setObject :catArray forKey: ExpDefaultCategoriesList];  
       
	// register the defaults 
	[[NSUserDefaults standardUserDefaults] registerDefaults: defaultValues]; 
 } 

PreferenceController

Now that we have created the standard defaults we need to be able to edit them. to start with we need to do a little editing to the PreferenceController class (again if you haven’t yet, create the files). In PreferenceController.m you just need to add an init method to return the correct nib file:

 - (id)init {  
	if (![ super initWithWindowNibName: @"Preferences"]) {  
		return nil; 
	}  
	return self; 
}  

AppController Part 2

Back in AppController.h we need to add one outlet and one method:

 @interface   AppController : NSObject  { 
      PreferenceController *preferenceController; 
 } 
   
 - (IBAction)showPreferencePanel:(id)sender; 
 @end 

Then in AppController.m we need to implement the method:

 - (IBAction) showPreferencePanel:(id)sender {  
	if   (!preferenceController) {  
		preferenceController= [[PreferenceController alloc] init]; 
	}  
	[preferenceController showWindow: self]; 
}  

Of course we need to hook up our new method to make it work. Open MainMenu.xib in IB, and add an NSObject from the ‘Objects & Controllers’ part of the Library (just drag it over to the IB document widow to add it). In the Inspector select the Idenity tab set the class to ‘AppController’ (if for  some reason it doesn’t show up make sure you have saved the AppController.h file). Then, select the application menu (the one with the apps name) and control drag from the ‘Preferences’ menu item to the AppController and select showPreferencePanel: as the action.

Preferences.xib

We are going to take care of setting preferences with an NSUserDefaultsController and bindings. To start with, if you haven’t already, create the Preferences.xib file, then open it in IB. Drag out the necessary controls to make it look something like this:

PreferencesXIB

To create the ‘+’ and ‘-’ buttons I used the NSAddTemplate and NSRemoveTemplate as the images.

Next, in IB,  drag over an NSUserDefaultsController to the Preferences.xib document. Also drag over an NSArrayController. In the inspector for the user defaults controller uncheck the “Applied Immediately.” checkbox (if you prefer you can leave it checked and changes to the user defaults will be saved as soon as they are changed. We will start bindings from the top down, so let’s start with the checkbox labeled “Open a new document.” Bind the value to User Defaults Controller.values.EmptyDocumentFlag.

firstPrefBinding

You may have noticed that we used the actual value of the string we declared as one of our externs not the extern variable, this is because IB has no knowledge of the externs so we have to use the values of the variables. In order to save problems later I would suggest using copy/paste to make sure you don’t mistype anything. The rest of the bindings should be pretty obvious, but here is the connections HUD for the User Defaults Controller. You should note that the DefaultCategoriesList gets bound to the array controller, this is so we can get add and delete for free. One really important thing to note is that the DefaultDateOffset needs to be bound to both the text field and the stepper, otherwise it won’t work right.

BindingsHUD

To get the table view to show the default categories select the table column (make sure you select the  column and not the scroll view or table view) and bind its value to the arranged objects of the array controller and set the key to “theString.” Then we need to hook-up all the actions. Control drag from the add button to the array controller and select the add: method do the same with the remove button (connecting it to remove: of course). After that, control drag from the save button to the user defaults controller and select the save: method, the restore defaults button will get connected to the revertToInitialValues: method. For some reason that I do not understand the revertToInitialValues: method does not actually work, the revert: method works great to delete unsaved changes and save: works as it should as well, if anyone has any ideas on what is missing please let me know.

Don’t forget to save everything, Next we will move on to implementing those preferences.

Implementing the preferences

Let’s start with the easiest one, opening a blank document on startup. To start with the AppController needs to be the app delegate in order to get the methods calls we will need. You can either do this by implementing the init method and then calling [NSApp setDelegate: self] or open MainMenu.xib in IB, control drag from File’s Owner to the App Controller instance and select delegate as the outlet. Then in AppController.m add the following method:

- (BOOL)applicationShouldOpenUntitledFile:(NSApplication *) sender { 
	return [[NSUserDefaults standardUserDefaults] boolForKey: ExpEmptyDocKey]; 
} 

That takes care of our first preference, now on to opening the last document that was opened when launching. This also takes advantage of one of the methods that gets called on the app delegate, so again in AppController.m add the following method:

- (  void  )applicationDidFinishLaunching:(  NSNotification   *)aNotification {    
	// see if we need to open the most recent document or not and do it if needed 
	if ([[NSUserDefaults standardUserDefaults] boolForKey: ExpOpenLastDocKey]) {  
		NSArray *anArray;  
		NSDocumentController*docController = [NSDocumentController sharedDocumentController]; 
		anArray = [docControllerrecentDocumentURLs];  
		if (anArray != nil && [anArray count ] > 0)  {  
			NSError *anError = nil;  
			[docController openDocumentWithContentsOfURL:[anArray objectAtIndex: 0] display: YES error:&anError]; 
		}  
	}  
}  

On order to implement the default date offset we are going to do things a little differently (we may be straying from MVC a bit here, but I think it works). This is the one time we will code the actual value of one of our externs, we are doing this so that the entity class can be reused in other apps more easily (we can declare a different extern in another app but define it the same way and have use of this method there). In Expense.h modify the awakeFromInsert method to look like this:

- (void) awakeFromInsert { 
	NSNumber *days = [[NSUserDefaults standardUserDefaults] objectForKey: @"DefaultDateOffset"]; 
	int dayInt = [days intValue]; 
	if (dayInt ==0) { 
		NSDate *now = [NSDate date]; 
		self.date = now; 
	} 
	else { 
		int seconds = dayInt * 86400;   // number of days times number of seconds in one day.  
		NSDate *theDate = [[NSDate alloc] initWithTimeIntervalSinceNow: seconds];  
		self.date = theDate; 
		// we called alloc therefore we call release, even though it will be released when this method ends anyway. 
		[theDate release];  
	} 
} 

For our final trick we will create some default categories for the user in all new documents. There are several steps involved in this but it all happens in one method in MyDocument. We are going to override the  method since it only gets called when new documents are created. Before we start adding categories to the document we will first need to disable undo tracking so the document doesn’t start out dirty, then we add the categories, process the changes, and re-enable undo tracking. I’ve commented the code here pretty well so you can follow along with what is happening easier. In MyDocument.m add the following method definition:

// Called only when document is first created 
- (id) initWithType: (NSString *) typeName error: (NSError **) outError { 
	// call the designated initalizer 
	MyDocument *document = [self init]; 

	// pass on the file type 
	[self setFileType: typeName]; 

	// disable undo tracking 
	NSManagedObjectContext *context = [self managedObjectContext]; 
	[[context undoManager] disableUndoRegistration]; 

	// check if the user want new documents to have basic entities added 
	NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; 
	if ([defaults boolForKey: ExpDefaultCategoriesKey]) { 
		// if the user wants the default items we hand them over 
		// Add the default entities 
		NSArray *categoryArray = [defaults objectForKey: ExpDefaultCategoriesList]; 
		NSDictionary *dictionary; 
		for (dictionary in categoryArray) { 
			Category *newCategory = [NSEntityDescription insertNewObjectForEntityForName: @"Category" inManagedObjectContext: [self managedObjectContext]];  
			[newCategory setValue: [dictionary objectForKey: @"theString"] forKey: @"name"]; 
		} 
	} 

	// enable undo tracking 
	[context processPendingChanges]; 
	[[context undoManager] enableUndoRegistration]; 

return document; 
} 

That’s all folks

That brings us to the end of our time together today. In our next session we will do some versioning, and if I can figure out we will add in some other features as well. If for some reason something isn’t working for you at this point here is a zip file of the project at this point, along with a sample file:

Expenses3.zip

There are two things I would like to add at this point but don’t know how to; an NSComboBox in place of the NSPopUpMenu in the Expenses tab (it would be nice to be able to type in the Category with autocompletion), the other thing is it would be nice to see how much was spent in each category per month along with the total for each month. I’m sure both of these things are fairly easy but they have eluded me thus far. For the combo box I have tried translating the bindings over but they don’t match and none of my attempts have worked. For the monthly totals I have tried numerous methods that have all failed. inserting attributes for each month with custom getter methods causes an error as soon as the second getter is fired. Attempts to use notifications have failed since they are sent far too often to be useful. I welcome any suggestions or ideas in either of these two areas and will ensure that all contributers get credit.

As always, I look forward to your comments, questions, complaints, suggestions, etc.

About theMIkeSwan

Entertainment Lighting Tech, OS X & iOS developer
This entry was posted in Core Data, Programming. Bookmark the permalink.

Leave a Reply