source: trunk/Cocoa/Pester/Source/PSAlarmSetController.m @ 53

Last change on this file since 53 was 53, checked in by Nicholas Riley, 19 years ago

Updated for Pester 1.1a5 (very limited release).

Pester 1.1a4 was never released.

File size: 22.2 KB
Line 
1//
2//  PSAlarmSetController.m
3//  Pester
4//
5//  Created by Nicholas Riley on Tue Oct 08 2002.
6//  Copyright (c) 2002 Nicholas Riley. All rights reserved.
7//
8
9#import "PSAlarmSetController.h"
10#import "PSAlarmAlertController.h"
11#import "PSPowerManager.h"
12#import "NJRDateFormatter.h"
13#import "NJRFSObjectSelector.h"
14#import "NJRIntervalField.h"
15#import "NJRQTMediaPopUpButton.h"
16#import "NJRVoicePopUpButton.h"
17#import "NSString-NJRExtensions.h"
18#import "NSAttributedString-NJRExtensions.h"
19#import "NSCalendarDate-NJRExtensions.h"
20#import <Carbon/Carbon.h>
21
22#import "PSAlerts.h"
23#import "PSDockBounceAlert.h"
24#import "PSScriptAlert.h"
25#import "PSNotifierAlert.h"
26#import "PSBeepAlert.h"
27#import "PSMovieAlert.h"
28#import "PSSpeechAlert.h"
29
30/* Bugs to file:
31
32¥ any trailing spaces: -> exception for +[NSCalendarDate dateWithNaturalLanguageString]:
33 > NSCalendarDate dateWithNaturalLanguageString: '12 '
34  format error: internal error
35
36¥ NSDate natural language stuff in NSCalendarDate (why?), misspelled category name
37¥ NSCalendarDate natural language stuff behaves differently from NSDateFormatter (AM/PM has no effect, shouldn't they share code?)
38¥ descriptionWithCalendarFormat:, dateWithNaturalLanguageString: does not default to current locale, instead it defaults to US unless you tell it otherwise
39¥ NSDateFormatter doc class description gives two examples for natural language that are incorrect, no link to NSDate doc that describes exactly how natural language dates are parsed
40¥ NSTimeFormatString does not include %p when it should, meaning that AM/PM is stripped yet 12-hour time is still used
41¥ NSNextDayDesignations, NSNextNextDayDesignations are noted as 'a string' in NSUserDefaults docs, but maybe they are actually an array, or either an array or a string, given their names?
42¥ "Setting the Format for Dates" does not document how to get 1:15 AM, the answer is %1I - strftime has no exact equivalent; the closest is %l.  strftime does not permit numeric prefixes.  It also refers to "NSCalendar" when no such class exists.
43¥ none of many mentions of NSAMPMDesignation indicates that they include the leading spaces (" AM", " PM").  In "Setting the Format for Dates", needs to mention that the leading spaces are not included in %p with strftime.  But if you use the NSCalendarDate stuff, it appears %p doesn't include the space (because it doesn't use the locale dictionary).
44¥ If you feed NSCalendarDate dateWithNaturalLanguageString: an " AM"/" PM" locale, it doesn't accept that date format.
45¥ descriptions for %X and %x are reversed (time zone is in %X, not %x)
46¥ NSComboBox data source issues, canÕt have it appear as ÒtodayÓ because the formatter doesnÕt like that.  Should be able to enter text into the data source and have the formatter process it without altering it.
47¥ too hard to implement date-only or time-only formatters
48¥ should be able to specify that natural language favors date or time (10 = 10th of month, not 10am)
49¥ please expose the iCal controls!
50
51*/
52
53static NSString * const PSAlertsSelected = @"Pester alerts selected"; // NSUserDefaults key
54static NSString * const PSAlertsEditing = @"Pester alerts editing"; // NSUserDefaults key
55
56@interface PSAlarmSetController (Private)
57
58- (void)_readAlerts:(PSAlerts *)alerts;
59- (BOOL)_setAlerts;
60- (void)_stopUpdateTimer;
61
62@end
63
64@implementation PSAlarmSetController
65
66- (void)awakeFromNib;
67{
68    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
69    alarm = [[PSAlarm alloc] init];
70    [[self window] center];
71    // XXX excessive retention of formatters?  check later...
72    [timeOfDay setFormatter: [[NJRDateFormatter alloc] initWithDateFormat: [NJRDateFormatter localizedTimeFormatIncludingSeconds: NO] allowNaturalLanguage: YES]];
73    [timeDate setFormatter: [[NJRDateFormatter alloc] initWithDateFormat: [NJRDateFormatter localizedDateFormatIncludingWeekday: NO] allowNaturalLanguage: YES]];
74    {
75        NSArray *dayNames = [defaults arrayForKey:
76            NSWeekDayNameArray];
77        NSArray *completions = [timeDateCompletions itemTitles];
78        NSEnumerator *e = [completions objectEnumerator];
79        NSString *title;
80        int itemIndex = 0;
81        NSRange matchingRange;
82        while ( (title = [e nextObject]) != nil) {
83            matchingRange = [title rangeOfString: @"ÇdayÈ"];
84            if (matchingRange.location != NSNotFound) {
85                NSMutableString *format = [title mutableCopy];
86                NSEnumerator *we = [dayNames objectEnumerator];
87                NSString *dayName;
88                [format deleteCharactersInRange: matchingRange];
89                [format insertString: @"%@" atIndex: matchingRange.location];
90                [timeDateCompletions removeItemAtIndex: itemIndex];
91                while ( (dayName = [we nextObject]) != nil) {
92                    [timeDateCompletions insertItemWithTitle: [NSString stringWithFormat: format, dayName] atIndex: itemIndex];
93                    itemIndex++;
94                }
95            } else itemIndex++;
96        }
97    }
98    [editAlert setIntValue: [defaults boolForKey: PSAlertsEditing]];
99    {
100        NSDictionary *plAlerts = [defaults dictionaryForKey: PSAlertsSelected];
101        PSAlerts *alerts;
102        if (plAlerts == nil) {
103            alerts = [[PSAlerts alloc] initWithPesterVersion1Alerts];
104        } else {
105            NS_DURING
106                alerts = [[PSAlerts alloc] initWithPropertyList: plAlerts];
107            NS_HANDLER
108                NSRunAlertPanel(@"Unable to restore alerts", @"Pester could not restore recent alert information for one or more alerts in the Set Alarm window.  The default set of alerts will be used instead.\n\n%@", nil, nil, nil, [localException reason]);
109                alerts = [[PSAlerts alloc] initWithPesterVersion1Alerts];
110            NS_ENDHANDLER
111        }
112        [self _readAlerts: alerts];
113    }
114    [timeDate setObjectValue: [NSDate date]];
115    [self inAtChanged: nil]; // by convention, if sender is nil, we're initializing
116    [self playSoundChanged: nil];
117    [self doScriptChanged: nil];
118    [self doSpeakChanged: nil];
119    [self editAlertChanged: nil];
120    [script setFileTypes: [NSArray arrayWithObjects: @"applescript", @"script", NSFileTypeForHFSTypeCode(kOSAFileType), NSFileTypeForHFSTypeCode('TEXT'), nil]];
121    [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(silence:) name: PSAlarmAlertStopNotification object: nil];
122    [[NSNotificationCenter defaultCenter] addObserver: self selector: @selector(playSoundChanged:) name: NJRQTMediaPopUpButtonMovieChangedNotification object: sound];
123    [voice setDelegate: self]; // XXX why don't we do this in IB?  It should use the accessor...
124    [wakeUp setEnabled: [PSPowerManager autoWakeSupported]];
125    // XXX workaround for 10.1.x and 10.2.x bug which sets the first responder to the wrong field alternately, but it works if I set the initial first responder to nil... go figure.
126    [[self window] setInitialFirstResponder: nil];
127    [[self window] makeKeyAndOrderFront: nil];
128}
129
130- (void)setStatus:(NSString *)aString;
131{
132    // NSLog(@"%@", alarm);
133    if (aString != status) {
134        [status release]; status = nil;
135        status = [aString retain];
136        [timeSummary setStringValue: status];
137    }
138}
139
140// XXX with -[NSControl currentEditor] don't need to compare?  Also check -[NSControl validateEditing]
141- (id)objectValueForTextField:(NSTextField *)field whileEditing:(id)sender;
142{
143    if (sender == field) {
144        NSString *stringValue = [[[self window] fieldEditor: NO forObject: field] string];
145        id obj = nil;
146        [[field formatter] getObjectValue: &obj forString: stringValue errorDescription: NULL];
147        // NSLog(@"from field editor: %@", obj);
148        return obj;
149    } else {
150        // NSLog(@"from field: %@", [field objectValue]);
151        return [field objectValue];
152    }
153}
154
155#pragma mark date/interval setting
156
157- (void)setAlarmDateAndInterval:(id)sender;
158{
159    if (isInterval) {
160        [alarm setInterval: [timeInterval interval]];
161    } else {
162        [alarm setForDate: [self objectValueForTextField: timeDate whileEditing: sender]
163                   atTime: [self objectValueForTextField: timeOfDay whileEditing: sender]];
164    }
165}
166
167- (void)_stopUpdateTimer;
168{
169    [updateTimer invalidate]; [updateTimer release]; updateTimer = nil;
170}
171
172// XXX use OACalendar in popup like Palm Desktop?
173
174- (IBAction)updateDateDisplay:(id)sender;
175{
176    // NSLog(@"updateDateDisplay: %@", sender);
177    if ([alarm isValid]) {
178        [self setStatus: [NSString stringWithFormat: @"Alarm will be set for %@ on %@", [alarm timeString], [alarm dateString]]];
179        [setButton setEnabled: YES];
180        if (updateTimer == nil || ![updateTimer isValid]) {
181            // XXX this logic (and the timer) should really go into PSAlarm, to send notifications for status updates instead.  Timer starts when people are watching, stops when people aren't.
182            // NSLog(@"setting timer");
183            if (isInterval) {
184                updateTimer = [NSTimer scheduledTimerWithTimeInterval: 1 target: self selector: @selector(updateDateDisplay:) userInfo: nil repeats: YES];
185            } else {
186                updateTimer = [NSTimer scheduledTimerWithTimeInterval: [alarm interval] target: self selector: @selector(updateDateDisplay:) userInfo: nil repeats: NO];
187            }
188            [updateTimer retain];
189        }
190    } else {
191        [setButton setEnabled: NO];
192        [self setStatus: [alarm invalidMessage]];
193        [self _stopUpdateTimer];
194    }
195}
196
197// Be careful not to hook up any of the text fields' actions to update: because we handle them in controlTextDidChange: instead.  If we could get the active text field somehow via public API (guess we could use controlTextDidBegin/controlTextDidEndEditing) then we'd not need to overload the update sender for this purpose.  Or, I guess, we could use another method other than update.  It should not be this hard to implement what is essentially standard behavior.  Sigh.
198// Note: finding out whether a given control is editing is easier.  See: <http://cocoa.mamasam.com/COCOADEV/2002/03/2/28501.php>.
199
200- (IBAction)update:(id)sender;
201{
202    // NSLog(@"update: %@", sender);
203    [self setAlarmDateAndInterval: sender];
204    [self updateDateDisplay: sender];
205}
206
207- (IBAction)inAtChanged:(id)sender;
208{
209    NSButtonCell *new = [inAtMatrix selectedCell], *old;
210    isInterval = ([inAtMatrix selectedTag] == 0);
211    old = [inAtMatrix cellWithTag: isInterval];
212    NSAssert(new != old, @"in and at buttons should be distinct!");
213    [old setKeyEquivalent: [new keyEquivalent]];
214    [old setKeyEquivalentModifierMask: [new keyEquivalentModifierMask]];
215    [new setKeyEquivalent: @""];
216    [new setKeyEquivalentModifierMask: 0];
217    [timeInterval setEnabled: isInterval];
218    [timeIntervalUnits setEnabled: isInterval];
219    [timeIntervalRepeats setEnabled: isInterval];
220    [timeOfDay setEnabled: !isInterval];
221    [timeDate setEnabled: !isInterval];
222    [timeDateCompletions setEnabled: !isInterval];
223    if (sender != nil)
224        [[self window] makeFirstResponder: isInterval ? (NSTextField *)timeInterval : timeOfDay];
225    // NSLog(@"UPDATING FROM inAtChanged");
226    [self update: nil];
227}
228
229- (IBAction)dateCompleted:(NSPopUpButton *)sender;
230{
231    [timeDate setStringValue: [sender titleOfSelectedItem]];
232    [self update: sender];
233}
234
235#pragma mark alert editing
236
237- (IBAction)editAlertChanged:(id)sender;
238{
239    BOOL editAlertSelected = [editAlert intValue];
240    NSView *editAlertControl = [editAlert controlView];
241    NSWindow *window = [self window];
242    NSRect frame = [window frame];
243    if (editAlertSelected) {
244        NSSize editWinSize = [window maxSize];
245        [editAlertControl setNextKeyView: [displayMessage controlView]];
246        frame.origin.y += frame.size.height - editWinSize.height;
247        frame.size = editWinSize;
248        [window setFrame: frame display: (sender != nil) animate: (sender != nil)];
249        [self updateDateDisplay: sender];
250        [alertTabs selectTabViewItemWithIdentifier: @"edit"];
251    } else {
252        NSSize viewWinSize = [window minSize];
253        NSRect textFrame = [alertView frame];
254        float textHeight;
255        if (![self _setAlerts]) {
256            [alertView setStringValue: [NSString stringWithFormat: @"CouldnÕt process alert information.\n%@", status]];
257        } else {
258            NSAttributedString *string = [[alarm alerts] prettyList];
259            if (string == nil) {
260                [alertView setStringValue: @"Do nothing. Click the button labeled ÒEditÓ to add an alert."];
261            } else {
262                [alertView setAttributedStringValue: string];
263                [self updateDateDisplay: sender];
264            }
265        }
266        if (sender != nil) { // nil == we're initializing, don't mess with focus
267            NSResponder *oldResponder = [window firstResponder];
268            // make sure focus doesn't get stuck in the edit tab: it is confusing and leaves behind artifacts
269            if (oldResponder == editAlertControl || [oldResponder isKindOfClass: [NSView class]] && [(NSView *)oldResponder isDescendantOf: alertTabs])
270                [window makeFirstResponder: messageField]; // would use editAlertControl, but can't get it to display anomaly-free.
271            [self silence: sender];
272        }
273        // allow height to expand, though not arbitrarily (should still fit on an 800x600 screen)
274        textHeight = [[alertView cell] cellSizeForBounds: NSMakeRect(0, 0, textFrame.size.width, 400)].height;
275        textFrame.origin.y += textFrame.size.height - textHeight;
276        textFrame.size.height = textHeight;
277        [alertView setFrame: textFrame];
278        viewWinSize.height += textHeight;
279        [alertTabs selectTabViewItemWithIdentifier: @"view"];
280        frame.origin.y += frame.size.height - viewWinSize.height;
281        frame.size = viewWinSize;
282        [window setFrame: frame display: (sender != nil) animate: (sender != nil)];
283        [editAlertControl setNextKeyView: cancelButton];
284    }
285    if (sender != nil) {
286        [[NSUserDefaults standardUserDefaults] setBool: editAlertSelected forKey: PSAlertsEditing];
287    }
288}
289
290
291- (IBAction)playSoundChanged:(id)sender;
292{
293    BOOL playSoundSelected = [playSound intValue];
294    BOOL canRepeat = playSoundSelected ? [sound canRepeat] : NO;
295    [sound setEnabled: playSoundSelected];
296    [soundRepetitions setEnabled: canRepeat];
297    [soundRepetitionStepper setEnabled: canRepeat];
298    [soundRepetitionsLabel setTextColor: canRepeat ? [NSColor controlTextColor] : [NSColor disabledControlTextColor]];
299    if (playSoundSelected && sender == playSound) {
300        [[self window] makeFirstResponder: sound];
301    }
302}
303
304- (IBAction)setSoundRepetitionCount:(id)sender;
305{
306    NSTextView *fieldEditor = (NSTextView *)[soundRepetitions currentEditor];
307    BOOL isEditing = (fieldEditor != nil);
308    int newReps = [sender intValue], oldReps;
309    if (isEditing) {
310        // XXX work around bug where if you ask soundRepetitions for its intValue too often while it's editing, the field begins to flash
311        oldReps = [[[fieldEditor textStorage] string] intValue];
312    } else oldReps = [soundRepetitions intValue];
313    if (newReps != oldReps) {
314        [soundRepetitions setIntValue: newReps];
315        // NSLog(@"updating: new value %d, old value %d%@", newReps, oldReps, isEditing ? @", is editing" : @"");
316        // XXX work around 10.1 bug, otherwise field only displays every second value
317        if (isEditing) [soundRepetitions selectText: self];
318    }
319}
320
321// XXX should check the 'Do script:' button when someone drops a script on the button
322
323- (IBAction)doScriptChanged:(id)sender;
324{
325    BOOL doScriptSelected = [doScript intValue];
326    [script setEnabled: doScriptSelected];
327    [scriptSelectButton setEnabled: doScriptSelected];
328    if (doScriptSelected && sender != nil) {
329        [[self window] makeFirstResponder: scriptSelectButton];
330        if ([script alias] == nil) [scriptSelectButton performClick: sender];
331    }
332}
333
334- (IBAction)doSpeakChanged:(id)sender;
335{
336    BOOL doSpeakSelected = [doSpeak state] == NSOnState;
337    [voice setEnabled: doSpeakSelected];
338    if (doSpeakSelected && sender != nil)
339        [[self window] makeFirstResponder: voice];
340}
341
342- (void)_readAlerts:(PSAlerts *)alerts;
343{
344    NSEnumerator *e = [alerts alertEnumerator];
345    PSAlert *alert;
346   
347    [alarm setAlerts: alerts];
348
349    // turn off all alerts
350    [bounceDockIcon setState: NSOffState];
351    [doScript setIntValue: NO];
352    [displayMessage setIntValue: NO];
353    [playSound setIntValue: NO];
354    [doSpeak setIntValue: NO];
355
356    while ( (alert = [e nextObject]) != nil) {
357        if ([alert isKindOfClass: [PSDockBounceAlert class]]) {
358            [bounceDockIcon setState: NSOnState];
359        } else if ([alert isKindOfClass: [PSScriptAlert class]]) {
360            [doScript setIntValue: YES];
361            [script setAlias: [(PSScriptAlert *)alert scriptFileAlias]];
362        } else if ([alert isKindOfClass: [PSNotifierAlert class]]) {
363            [displayMessage setIntValue: YES];
364        } else if ([alert isKindOfClass: [PSBeepAlert class]]) {
365            unsigned int repetitions = [(PSBeepAlert *)alert repetitions];
366            [playSound setIntValue: YES];
367            [sound setAlias: nil];
368            [soundRepetitions setIntValue: repetitions];
369            [soundRepetitionStepper setIntValue: repetitions];
370        } else if ([alert isKindOfClass: [PSMovieAlert class]]) {
371            unsigned int repetitions = [(PSMovieAlert *)alert repetitions];
372            [playSound setIntValue: YES];
373            [sound setAlias: [(PSMovieAlert *)alert movieFileAlias]];
374            [soundRepetitions setIntValue: repetitions];
375            [soundRepetitionStepper setIntValue: repetitions];
376        } else if ([alert isKindOfClass: [PSSpeechAlert class]]) {
377            [doSpeak setIntValue: YES];
378            [voice setVoice: [(PSSpeechAlert *)alert voice]];
379        }
380    }
381}
382
383- (BOOL)_setAlerts;
384{
385    PSAlerts *alerts = [alarm alerts];
386   
387    [alerts removeAlerts];
388    NS_DURING
389        // dock bounce alert
390        if ([bounceDockIcon state] == NSOnState)
391            [alerts addAlert: [PSDockBounceAlert alert]];
392        // script alert
393        if ([doScript intValue]) {
394            BDAlias *scriptFileAlias = [script alias];
395            if (scriptFileAlias == nil) {
396                [self setStatus: @"Unable to set script alert (no script specified?)"];
397                return NO;
398            }
399            [alerts addAlert: [PSScriptAlert alertWithScriptFileAlias: scriptFileAlias]];
400        }
401        // notifier alert
402        if ([displayMessage intValue])
403            [alerts addAlert: [PSNotifierAlert alert]];
404        // sound alerts
405        if ([playSound intValue]) {
406            BDAlias *soundAlias = [sound selectedAlias];
407            unsigned short numReps = [soundRepetitions intValue];
408            if (soundAlias == nil) // beep alert
409                [alerts addAlert: [PSBeepAlert alertWithRepetitions: numReps]];
410            else // movie alert
411                [alerts addAlert: [PSMovieAlert alertWithMovieFileAlias: soundAlias repetitions: numReps]];
412        }
413        // speech alert
414        if ([doSpeak intValue])
415            [alerts addAlert: [PSSpeechAlert alertWithVoice: [voice titleOfSelectedItem]]];
416        [[NSUserDefaults standardUserDefaults] setObject: [alerts propertyListRepresentation] forKey: PSAlertsSelected];
417    NS_HANDLER
418        [self setStatus: [localException reason]];
419        NS_VALUERETURN(NO, BOOL);
420    NS_ENDHANDLER
421    return YES;
422}
423
424#pragma mark actions
425
426// to ensure proper updating of interval, this should be the only method by which the window is shown (e.g. from the Alarm menu)
427- (IBAction)showWindow:(id)sender;
428{
429    if (![[self window] isVisible]) {
430        NSDate *today = [NSCalendarDate dateForDay: [NSDate date]];
431        if ([(NSDate *)[timeDate objectValue] compare: today] == NSOrderedAscending) {
432            [timeDate setObjectValue: today];
433        }
434        [self update: self];
435        // XXX bug workaround - otherwise, first responder appears to alternate every time the window is shown.  And if you set the initial first responder, you can't tab in the window. :(
436        [[self window] makeFirstResponder: [[self window] initialFirstResponder]];
437    }
438    [super showWindow: sender];
439}
440
441- (IBAction)setAlarm:(NSButton *)sender;
442{
443    // set alerts before setting alarm...
444    if (![self _setAlerts]) return;
445
446    // set alarm
447    [self setAlarmDateAndInterval: sender];
448    [alarm setRepeating: [timeIntervalRepeats state] == NSOnState];
449    [alarm setMessage: [messageField stringValue]];
450    if (![alarm setTimer]) {
451        [self setStatus: [@"Unable to set alarm.  " stringByAppendingString: [alarm invalidMessage]]];
452        return;
453    }
454   
455    [self setStatus: [[alarm date] descriptionWithCalendarFormat: @"Alarm set for %x at %X" timeZone: nil locale: nil]];
456    [[self window] close];
457    [alarm release];
458    alarm = [[PSAlarm alloc] init];
459}
460
461- (IBAction)silence:(id)sender;
462{
463    [sound stopSoundPreview: self];
464    [voice stopVoicePreview: self];
465}
466
467@end
468
469@implementation PSAlarmSetController (NSControlSubclassDelegate)
470
471- (BOOL)control:(NSControl *)control didFailToFormatString:(NSString *)string errorDescription:(NSString *)error;
472{
473    if (control == timeInterval)
474        [timeInterval handleDidFailToFormatString: string errorDescription: error label: @"alarm interval"];
475    return NO;
476}
477
478- (void)control:(NSControl *)control didFailToValidatePartialString:(NSString *)string errorDescription:(NSString *)error;
479{
480    // NSLog(@"UPDATING FROM validation");
481    if (control == timeInterval) [self update: timeInterval]; // make sure we still examine the field editor, otherwise if the existing numeric string is invalid, it'll be cleared
482}
483
484@end
485
486@implementation PSAlarmSetController (NSWindowNotifications)
487
488- (void)windowWillClose:(NSNotification *)notification;
489{
490    // NSLog(@"stopping update timer");
491    [self silence: nil];
492    [self _stopUpdateTimer];
493    [self _setAlerts];
494}
495
496@end
497
498@implementation PSAlarmSetController (NSControlSubclassNotifications)
499
500// called because we're the delegate
501
502- (void)controlTextDidChange:(NSNotification *)notification;
503{
504    // NSLog(@"UPDATING FROM controlTextDidChange: %@", [notification object]);
505    [self update: [notification object]];
506}
507
508@end
509
510@implementation PSAlarmSetController (NJRVoicePopUpButtonDelegate)
511
512- (NSString *)voicePopUpButton:(NJRVoicePopUpButton *)sender previewStringForVoice:(NSString *)voice;
513{
514    NSString *message = [messageField stringValue];
515    if (message == nil || [message length] == 0)
516        message = [alarm message];
517    return message;
518}
519
520@end
Note: See TracBrowser for help on using the repository browser.