//============================================================================== // This file is part of Master Password. // Copyright (c) 2011-2017, Maarten Billemont. // // Master Password is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Master Password is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You can find a copy of the GNU General Public License in the // LICENSE file. Alternatively, see . //============================================================================== #import "MPUsersViewController.h" #import "MPEntities.h" #import "MPAvatarCell.h" #import "MPiOSAppDelegate.h" #import "MPAppDelegate_Store.h" #import "MPAppDelegate_Key.h" #import "MPWebViewController.h" typedef NS_OPTIONS( NSUInteger, MPUsersTips ) { MPUsersThanksTip = 1 << 0, MPUsersAvatarTip = 1 << 1, MPUsersMasterPasswordTip = 1 << 2, MPUsersPreferencesTip = 1 << 3, }; typedef NS_ENUM( NSUInteger, MPActiveUserState ) { /** The users are all inactive */ MPActiveUserStateNone, /** The selected user is activated and being logged in with */ MPActiveUserStateLogin, /** The selected user is activated and its user name is being asked for */ MPActiveUserStateUserName, /** The selected user is activated and its new master password is being asked for */ MPActiveUserStateMasterPasswordChoice, /** The selected user is activated and the confirmation of the previously entered master password is being asked for */ MPActiveUserStateMasterPasswordConfirmation, /** The selected user is activated displayed at the top with the rest of the UI inactive */ MPActiveUserStateMinimized, }; @interface MPUsersViewController() @property(nonatomic) MPActiveUserState activeUserState; @property(nonatomic, strong) NSArray *userIDs; @property(nonatomic, strong) NSTimer *marqueeTipTimer; @property(nonatomic, strong) NSArray *marqueeTipTexts; @property(nonatomic) NSUInteger marqueeTipTextIndex; @end @implementation MPUsersViewController { NSString *_masterPasswordChoice; NSOperationQueue *_afterUpdates; } - (void)viewDidLoad { [super viewDidLoad]; _afterUpdates = [NSOperationQueue new]; self.marqueeTipTexts = @[ strl( @"Thanks, lhunath ➚" ), strl( @"Press and hold to delete or reset user." ), strl( @"Shake for emergency generator." ), ]; self.view.backgroundColor = [UIColor clearColor]; self.avatarCollectionView.allowsMultipleSelection = YES; [self.entryField addTarget:self action:@selector( textFieldEditingChanged: ) forControlEvents:UIControlEventEditingChanged]; self.preferencesTipContainer.visible = NO; [self setActive:YES animated:NO]; if (![[NSUserDefaults standardUserDefaults] boolForKey:@"tipped.thanks"]) [self showTips:MPUsersThanksTip]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; self.userSelectionContainer.visible = NO; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; PearlRemoveNotificationObservers(); [self.marqueeTipTimer invalidate]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; [self registerObservers]; [self reloadUsers]; [self.marqueeTipTimer invalidate]; self.marqueeTipTimer = [NSTimer scheduledTimerWithTimeInterval:5 target:self selector:@selector( firedMarqueeTimer: ) userInfo:nil repeats:YES]; [self firedMarqueeTimer:nil]; } - (void)viewWillLayoutSubviews { [self.avatarCollectionView.collectionViewLayout invalidateLayout]; [super viewWillLayoutSubviews]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [self.avatarCollectionView.collectionViewLayout invalidateLayout]; [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; } - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { if ([segue.identifier isEqualToString:@"web"]) ((MPWebViewController *)segue.destinationViewController).initialURL = [NSURL URLWithString:@"http://thanks.lhunath.com"]; } #pragma mark - UITextFieldDelegate - (void)textFieldDidEndEditing:(UITextField *)textField { } - (BOOL)textFieldShouldReturn:(UITextField *)textField { if (textField == self.entryField) { switch (self.activeUserState) { case MPActiveUserStateNone: { [textField resignFirstResponder]; break; } case MPActiveUserStateLogin: { self.entryField.enabled = NO; [self selectedAvatar].spinnerActive = YES; NSString *masterPassword = self.entryField.text; if (![MPiOSAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { BOOL signedIn = NO, isNew = NO; MPUserEntity *user = [self selectedUserInContext:context isNew:&isNew]; if (!isNew && user) signedIn = [[MPiOSAppDelegate get] signInAsUser:user saveInContext:context usingMasterPassword:masterPassword]; [[NSOperationQueue mainQueue] addOperationWithBlock:^{ self.entryField.text = @""; self.entryField.enabled = YES; [self selectedAvatar].spinnerActive = NO; if (!signedIn) { // Sign in failed. [self showEntryTip:strl( @"Looks like a typo!\nTry again; that password was incorrect." )]; return; } }]; }]) { self.entryField.enabled = YES; [self selectedAvatar].spinnerActive = NO; } break; } case MPActiveUserStateUserName: { NSString *userName = self.entryField.text; if (![userName length]) { // No name entered. [self showEntryTip:strl( @"First, enter your name." )]; return NO; } [self selectedAvatar].name = userName; self.activeUserState = MPActiveUserStateMasterPasswordChoice; break; } case MPActiveUserStateMasterPasswordChoice: { NSString *masterPassword = self.entryField.text; if (![masterPassword length]) { // No password entered. [self showEntryTip:strl( @"Pick a master password." )]; return NO; } self.activeUserState = MPActiveUserStateMasterPasswordConfirmation; break; } case MPActiveUserStateMasterPasswordConfirmation: { NSString *masterPassword = self.entryField.text; if (![masterPassword length]) { // No password entered. [self showEntryTip:strl( @"Confirm your master password." )]; return NO; } if (![masterPassword isEqualToString:_masterPasswordChoice]) { // Master password confirmation failed. [self showEntryTip:strl( @"Looks like a typo!\nTry again; enter your master password twice." )]; self.activeUserState = MPActiveUserStateMasterPasswordChoice; return NO; } self.entryField.enabled = NO; MPAvatarCell *avatarCell = [self selectedAvatar]; avatarCell.spinnerActive = YES; NSUInteger newUserAvatar = avatarCell.avatar; NSString *newUserName = avatarCell.name; if (![MPiOSAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { BOOL isNew = NO; MPUserEntity *user = [self userForAvatar:avatarCell inContext:context isNew:&isNew]; if (isNew) { user = [MPUserEntity insertNewObjectInContext:context]; user.algorithm = MPAlgorithmDefault; user.defaultType = user.algorithm.defaultType; user.avatar = newUserAvatar; user.name = newUserName; } BOOL signedIn = [[MPiOSAppDelegate get] signInAsUser:user saveInContext:context usingMasterPassword:masterPassword]; PearlMainQueue( ^{ self.entryField.text = @""; self.entryField.enabled = YES; avatarCell.spinnerActive = NO; if (!signedIn) { // Sign in failed, shouldn't happen for a new user. [self showEntryTip:strl( @"Couldn't create new user." )]; self.activeUserState = MPActiveUserStateNone; return; } } ); }]) { self.entryField.enabled = YES; avatarCell.spinnerActive = NO; } break; } case MPActiveUserStateMinimized: { [textField resignFirstResponder]; break; } } } return NO; } // This isn't really in UITextFieldDelegate. We fake it from UITextFieldTextDidChangeNotification. - (void)textFieldEditingChanged:(UITextField *)textField { if (textField == self.entryField) { switch (self.activeUserState) { case MPActiveUserStateNone: break; case MPActiveUserStateLogin: break; case MPActiveUserStateUserName: { NSString *userName = self.entryField.text; [self selectedAvatar].name = [userName length]? userName: strl( @"New User" ); break; } case MPActiveUserStateMasterPasswordChoice: break; case MPActiveUserStateMasterPasswordConfirmation: break; case MPActiveUserStateMinimized: break; } } } #pragma mark - UICollectionViewDelegateFlowLayout - (CGSize) collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section { CGSize parentSize = self.avatarCollectionView.bounds.size; return CGSizeMake( parentSize.width / 4, parentSize.height ); } - (CGSize) collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section { CGSize parentSize = self.avatarCollectionView.bounds.size; return CGSizeMake( parentSize.width / 4, parentSize.height ); } - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { CGSize parentSize = self.avatarCollectionView.bounds.size; return CGSizeMake( parentSize.width / 2, parentSize.height ); } #pragma mark - UICollectionViewDataSource - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { if (collectionView == self.avatarCollectionView) return [self.userIDs count] + 1; Throw( @"unexpected collection view: %@", collectionView ); } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { if (collectionView == self.avatarCollectionView) { MPAvatarCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:[MPAvatarCell reuseIdentifier] forIndexPath:indexPath]; cell.contentView.frame = cell.bounds; [cell addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector( didLongPress: )]]; [self updateModeForAvatar:cell atIndexPath:indexPath animated:NO]; [self updateVisibilityForAvatar:cell atIndexPath:indexPath animated:NO]; BOOL isNew = NO; MPUserEntity *user = [self userForIndexPath:indexPath inContext:[MPiOSAppDelegate managedObjectContextForMainThreadIfReady] isNew:&isNew]; if (isNew) // New User cell.avatar = MPAvatarAdd; else { // Existing User cell.avatar = user.avatar; cell.name = user.name; } return cell; } Throw( @"unexpected collection view: %@", collectionView ); } - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { if (collectionView == self.avatarCollectionView) { [self.avatarCollectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES]; // Deselect all other cells. for (NSUInteger otherItem = 0; otherItem < [collectionView numberOfItemsInSection:indexPath.section]; ++otherItem) if (otherItem != indexPath.item) { NSIndexPath *otherIndexPath = [NSIndexPath indexPathForItem:otherItem inSection:indexPath.section]; [collectionView deselectItemAtIndexPath:otherIndexPath animated:YES]; } BOOL isNew = NO; NSManagedObjectContext *mainContext = [MPiOSAppDelegate managedObjectContextForMainThreadIfReady]; MPUserEntity *mainUser = [self userForIndexPath:indexPath inContext:mainContext isNew:&isNew]; if (isNew) self.activeUserState = MPActiveUserStateUserName; else if (!mainUser.keyID) self.activeUserState = MPActiveUserStateMasterPasswordChoice; else { self.activeUserState = MPActiveUserStateLogin; self.entryField.enabled = NO; MPAvatarCell *userAvatar = [self selectedAvatar]; userAvatar.spinnerActive = YES; if (!isNew && mainUser && [MPiOSAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { MPUserEntity *user = [MPUserEntity existingObjectWithID:mainUser.objectID inContext:context]; BOOL signedIn = [[MPiOSAppDelegate get] signInAsUser:user saveInContext:context usingMasterPassword:nil]; PearlMainQueue( ^{ self.entryField.text = @""; self.entryField.enabled = YES; userAvatar.spinnerActive = NO; if (!signedIn) [self.entryField becomeFirstResponder]; } ); }]) return; self.entryField.text = @""; self.entryField.enabled = YES; userAvatar.spinnerActive = NO; [self.entryField becomeFirstResponder]; } } } - (void)collectionView:(UICollectionView *)collectionView didDeselectItemAtIndexPath:(NSIndexPath *)indexPath { if (collectionView == self.avatarCollectionView) self.activeUserState = MPActiveUserStateNone; } #pragma mark - UILongPressGestureRecognizer - (void)didLongPress:(UILongPressGestureRecognizer *)recognizer { if ([recognizer.view isKindOfClass:[MPAvatarCell class]]) { if (recognizer.state != UIGestureRecognizerStateBegan) // Don't show the action menu unless the state is Began. return; MPAvatarCell *avatarCell = (MPAvatarCell *)recognizer.view; NSManagedObjectContext *mainContext = [MPiOSAppDelegate managedObjectContextForMainThreadIfReady]; BOOL isNew = NO; MPUserEntity *user = [self userForAvatar:avatarCell inContext:mainContext isNew:&isNew]; NSManagedObjectID *userID = user.objectID; if (isNew || !user) return; [PearlSheet showSheetWithTitle:user.name viewStyle:UIActionSheetStyleBlackTranslucent initSheet:nil tappedButtonBlock:^(UIActionSheet *sheet, NSInteger buttonIndex) { if (buttonIndex == [sheet cancelButtonIndex]) return; if (buttonIndex == [sheet destructiveButtonIndex]) { // Delete User [PearlAlert showParentalGateWithTitle:@"Deleting User" message: @"The user and its sites will be deleted.\nPlease confirm by solving:" completion:^(BOOL continuing) { if (continuing) [self deleteUser:userID]; }]; return; } if (buttonIndex == [sheet firstOtherButtonIndex]) // Reset Password [PearlAlert showParentalGateWithTitle:@"Resetting User" message: @"The user's master password will be reset.\nPlease confirm by solving:" completion:^(BOOL continuing) { if (continuing) [self resetUser:userID avatar:avatarCell]; }]; } cancelTitle:[PearlStrings get].commonButtonCancel destructiveTitle:@"Delete User" otherTitles:@"Reset Password", nil]; } } #pragma mark - UIScrollViewDelegate - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { if (scrollView == self.avatarCollectionView) { CGPoint offsetToCenter = self.avatarCollectionView.center; NSIndexPath *avatarIndexPath = [self.avatarCollectionView indexPathForItemAtPoint: CGPointPlusCGPoint( *targetContentOffset, offsetToCenter )]; CGPoint targetCenter = [self.avatarCollectionView layoutAttributesForItemAtIndexPath:avatarIndexPath].center; *targetContentOffset = CGPointMinusCGPoint( targetCenter, offsetToCenter ); NSAssert( [self.avatarCollectionView indexPathForItemAtPoint:targetCenter].item == avatarIndexPath.item, @"should be same item" ); } } #pragma mark - Private - (void)deleteUser:(NSManagedObjectID *)userID { [MPiOSAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { MPUserEntity *user_ = [MPUserEntity existingObjectWithID:userID inContext:context]; if (!user_) return; [context deleteObject:user_]; [context saveToStore]; [self reloadUsers]; // I do NOT understand why our ObjectsDidChangeNotification isn't firing on saveToStore. }]; } - (void)resetUser:(NSManagedObjectID *)userID avatar:(MPAvatarCell *)avatarCell { [MPiOSAppDelegate managedObjectContextPerformBlock:^(NSManagedObjectContext *context) { MPUserEntity *user_ = [MPUserEntity existingObjectWithID:userID inContext:context]; if (!user_) return; [[MPiOSAppDelegate get] changeMasterPasswordFor:user_ saveInContext:context didResetBlock:^{ PearlMainQueue( ^{ NSIndexPath *avatarIndexPath = [self.avatarCollectionView indexPathForCell:avatarCell]; [self.avatarCollectionView selectItemAtIndexPath:avatarIndexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; [self collectionView:self.avatarCollectionView didSelectItemAtIndexPath:avatarIndexPath]; } ); }]; }]; } - (void)showTips:(MPUsersTips)showTips { [UIView animateWithDuration:0.3f animations:^{ if (showTips & MPUsersThanksTip) self.thanksTipContainer.visible = YES; if (showTips & MPUsersAvatarTip) self.avatarTipContainer.visible = YES; if (showTips & MPUsersMasterPasswordTip) self.entryTipContainer.visible = YES; if (showTips & MPUsersPreferencesTip) self.preferencesTipContainer.visible = YES; } completion:^(BOOL finished) { if (finished) PearlMainQueueAfter( 5, ^{ [UIView animateWithDuration:0.3f animations:^{ if (showTips & MPUsersThanksTip) self.thanksTipContainer.visible = NO; if (showTips & MPUsersAvatarTip) self.avatarTipContainer.visible = NO; if (showTips & MPUsersMasterPasswordTip) self.entryTipContainer.visible = NO; if (showTips & MPUsersPreferencesTip) self.preferencesTipContainer.visible = NO; }]; } ); }]; } - (void)showEntryTip:(NSString *)message { NSUInteger newlineIndex = [message rangeOfString:@"\n"].location; NSString *messageTitle = newlineIndex == NSNotFound? message: [message substringToIndex:newlineIndex]; NSString *messageSubtitle = newlineIndex == NSNotFound? nil: [message substringFromIndex:newlineIndex]; self.entryTipTitleLabel.text = messageTitle; self.entryTipSubtitleLabel.text = messageSubtitle; [self showTips:MPUsersMasterPasswordTip]; } - (void)firedMarqueeTimer:(NSTimer *)timer { NSString *nextMarqueeString = self.marqueeTipTexts[self.marqueeTipTextIndex++ % [self.marqueeTipTexts count]]; if ([nextMarqueeString isEqualToString:[self.marqueeButton titleForState:UIControlStateNormal]]) return; [UIView animateWithDuration:timer? 0.5f: 0 animations:^{ self.marqueeButton.visible = NO; } completion:^(BOOL finished) { if (!finished) return; [self.marqueeButton setTitle:nextMarqueeString forState:UIControlStateNormal]; [UIView animateWithDuration:timer? 0.5f: 0 animations:^{ self.marqueeButton.visible = YES; }]; }]; } - (MPAvatarCell *)selectedAvatar { NSArray *selectedIndexPaths = self.avatarCollectionView.indexPathsForSelectedItems; if (![selectedIndexPaths count]) { // No selected user. return nil; } return (MPAvatarCell *)[self.avatarCollectionView cellForItemAtIndexPath:selectedIndexPaths.firstObject]; } - (MPUserEntity *)selectedUserInContext:(NSManagedObjectContext *)context isNew:(BOOL *)isNew { MPAvatarCell *selectedAvatar = [self selectedAvatar]; if (!selectedAvatar) { // No selected user. *isNew = NO; return nil; } return [self userForAvatar:selectedAvatar inContext:context isNew:isNew]; } - (MPUserEntity *)userForAvatar:(MPAvatarCell *)cell inContext:(NSManagedObjectContext *)context isNew:(BOOL *)isNew { return [self userForIndexPath:[self.avatarCollectionView indexPathForCell:cell] inContext:context isNew:isNew]; } - (MPUserEntity *)userForIndexPath:(NSIndexPath *)indexPath inContext:(NSManagedObjectContext *)context isNew:(BOOL *)isNew { if ((*isNew = indexPath.item >= [self.userIDs count])) return nil; return [MPUserEntity existingObjectWithID:self.userIDs[indexPath.item] inContext:context]; } - (void)updateAvatarVisibility { for (NSIndexPath *indexPath in self.avatarCollectionView.indexPathsForVisibleItems) { MPAvatarCell *cell = (MPAvatarCell *)[self.avatarCollectionView cellForItemAtIndexPath:indexPath]; [self updateVisibilityForAvatar:cell atIndexPath:indexPath animated:NO]; } } - (void)updateModeForAvatar:(MPAvatarCell *)avatarCell atIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated { switch (self.activeUserState) { case MPActiveUserStateNone: { [self.avatarCollectionView deselectItemAtIndexPath:indexPath animated:YES]; [avatarCell setMode:MPAvatarModeLowered animated:animated]; break; } case MPActiveUserStateLogin: case MPActiveUserStateUserName: case MPActiveUserStateMasterPasswordChoice: case MPActiveUserStateMasterPasswordConfirmation: { if ([self.avatarCollectionView.indexPathsForSelectedItems containsObject:indexPath]) [avatarCell setMode:MPAvatarModeRaisedAndActive animated:animated]; else [avatarCell setMode:MPAvatarModeRaisedButInactive animated:animated]; break; } case MPActiveUserStateMinimized: { if ([self.avatarCollectionView.indexPathsForSelectedItems containsObject:indexPath]) [avatarCell setMode:MPAvatarModeRaisedAndMinimized animated:animated]; else [avatarCell setMode:MPAvatarModeRaisedAndHidden animated:animated]; break; } } } - (void)updateVisibilityForAvatar:(MPAvatarCell *)cell atIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated { CGFloat current = [self.avatarCollectionView layoutAttributesForItemAtIndexPath:indexPath].center.x - self.avatarCollectionView.contentOffset.x; CGFloat max = self.avatarCollectionView.bounds.size.width; CGFloat visibility = MAX( 0, MIN( 1, 1 - ABS( current / (max / 2) - 1 ) ) ); [cell setVisibility:visibility animated:animated]; [UIView animateWithDuration:animated? 0.3f: 0 animations:^{ self.nextAvatarButton.visible = self.previousAvatarButton.visible = cell.newUser && cell.mode == MPAvatarModeRaisedAndActive; self.nextAvatarButton.alpha = self.previousAvatarButton.alpha = visibility * 0.7f; }]; } - (void)afterUpdatesMainQueue:(void ( ^ )(void))block { [_afterUpdates addOperationWithBlock:^{ PearlMainQueue( block ); }]; } - (void)registerObservers { [self removeKeyPathObservers]; [self observeKeyPath:@"avatarCollectionView.contentOffset" withBlock: ^(id from, id to, NSKeyValueChange cause, MPUsersViewController *_self) { [_self updateAvatarVisibility]; }]; PearlRemoveNotificationObservers(); PearlAddNotificationObserver( UIApplicationDidEnterBackgroundNotification, nil, [NSOperationQueue mainQueue], ^(MPUsersViewController *self, NSNotification *note) { self.userSelectionContainer.visible = NO; } ); PearlAddNotificationObserver( UIApplicationWillEnterForegroundNotification, nil, [NSOperationQueue mainQueue], ^(MPUsersViewController *self, NSNotification *note) { [self reloadUsers]; } ); PearlAddNotificationObserver( UIApplicationDidBecomeActiveNotification, nil, [NSOperationQueue mainQueue], ^(MPUsersViewController *self, NSNotification *note) { [UIView animateWithDuration:0.5f animations:^{ self.userSelectionContainer.visible = YES; }]; } ); PearlAddNotificationObserver( UIKeyboardWillShowNotification, nil, [NSOperationQueue mainQueue], ^(MPUsersViewController *self, NSNotification *note) { CGRect keyboardRect = [note.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; CGFloat keyboardHeight = CGRectGetHeight( self.view.window.screen.bounds ) - CGRectGetMinY( keyboardRect ); [self.keyboardHeightConstraint updateConstant:keyboardHeight]; } ); NSManagedObjectContext *mainContext = [MPiOSAppDelegate managedObjectContextForMainThreadIfReady]; [UIView animateWithDuration:0.3f animations:^{ self.avatarCollectionView.visible = mainContext != nil; }]; if (mainContext && self.storeLoadingActivity.isAnimating) [self.storeLoadingActivity stopAnimating]; if (!mainContext && !self.storeLoadingActivity.isAnimating) [self.storeLoadingActivity startAnimating]; if (mainContext) PearlAddNotificationObserver( NSManagedObjectContextObjectsDidChangeNotification, mainContext, nil, ^(MPUsersViewController *self, NSNotification *note) { NSSet *insertedObjects = note.userInfo[NSInsertedObjectsKey]; NSSet *deletedObjects = note.userInfo[NSDeletedObjectsKey]; if ([[NSSetUnion( insertedObjects, deletedObjects ) filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { return [evaluatedObject isKindOfClass:[MPUserEntity class]]; }]] count]) [self reloadUsers]; } ); PearlAddNotificationObserver( NSPersistentStoreCoordinatorStoresWillChangeNotification, [MPiOSAppDelegate get].storeCoordinator, nil, ^(MPUsersViewController *self, NSNotification *note) { self.userIDs = nil; } ); PearlAddNotificationObserver( NSPersistentStoreCoordinatorStoresDidChangeNotification, [MPiOSAppDelegate get].storeCoordinator, nil, ^(MPUsersViewController *self, NSNotification *note) { PearlMainQueue( ^{ [self registerObservers]; [self reloadUsers]; } ); } ); } - (void)reloadUsers { [self afterUpdatesMainQueue:^{ if (![MPiOSAppDelegate managedObjectContextForMainThreadPerformBlockAndWait:^(NSManagedObjectContext *mainContext) { NSError *error = nil; NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass( [MPUserEntity class] )]; fetchRequest.sortDescriptors = @[ [NSSortDescriptor sortDescriptorWithKey:NSStringFromSelector( @selector( lastUsed ) ) ascending:NO] ]; NSArray *users = [mainContext executeFetchRequest:fetchRequest error:&error]; if (!users) { err( @"Failed to load users: %@", [error fullDescription] ); self.userIDs = nil; } NSMutableArray *userIDs = [NSMutableArray arrayWithCapacity:[users count]]; for (MPUserEntity *user in users) [userIDs addObject:user.objectID]; self.userIDs = userIDs; }]) self.userIDs = nil; }]; } #pragma mark - Properties - (void)setActive:(BOOL)active animated:(BOOL)animated { _active = active; if (active) [self setActiveUserState:MPActiveUserStateNone animated:animated]; else [self setActiveUserState:MPActiveUserStateMinimized animated:animated]; } - (void)setUserIDs:(NSArray *)userIDs { _userIDs = userIDs; PearlMainQueue( ^{ BOOL isNew = NO; NSManagedObjectID *selectUserID = [MPiOSAppDelegate get].activeUserOID; if (!selectUserID) selectUserID = [self selectedUserInContext:[MPiOSAppDelegate managedObjectContextForMainThreadIfReady] isNew:&isNew].objectID; [self.avatarCollectionView reloadData]; NSUInteger selectedAvatarItem = isNew? [_userIDs count]: selectUserID? [_userIDs indexOfObject:selectUserID]: NSNotFound; if (selectedAvatarItem != NSNotFound) [self.avatarCollectionView selectItemAtIndexPath:[NSIndexPath indexPathForItem:selectedAvatarItem inSection:0] animated:NO scrollPosition:UICollectionViewScrollPositionCenteredHorizontally]; [UIView animateWithDuration:0.3f animations:^{ self.userSelectionContainer.visible = YES; }]; } ); } - (void)setActiveUserState:(MPActiveUserState)activeUserState { [self setActiveUserState:activeUserState animated:YES]; } - (void)setActiveUserState:(MPActiveUserState)activeUserState animated:(BOOL)animated { _activeUserState = activeUserState; _masterPasswordChoice = nil; if (activeUserState != MPActiveUserStateMinimized && (!self.active || [MPiOSAppDelegate get].activeUserOID)) { [[MPiOSAppDelegate get] signOutAnimated:YES]; return; } // Set the entry container's contents. [_afterUpdates setSuspended:YES]; __block BOOL requestFirstResponder = NO; [self.view layoutIfNeeded]; [UIView animateWithDuration:animated? 0.4f: 0 animations:^{ switch (activeUserState) { case MPActiveUserStateNone: break; case MPActiveUserStateLogin: { self.entryLabel.text = strl( @"Enter your master password:" ); self.entryField.secureTextEntry = YES; self.entryField.autocapitalizationType = UITextAutocapitalizationTypeNone; self.entryField.text = nil; break; } case MPActiveUserStateUserName: { self.entryLabel.text = strl( @"Enter your full name:" ); self.entryField.secureTextEntry = NO; self.entryField.autocapitalizationType = UITextAutocapitalizationTypeWords; self.entryField.text = nil; break; } case MPActiveUserStateMasterPasswordChoice: { self.entryLabel.text = strl( @"Choose your master password:" ); self.entryField.secureTextEntry = YES; self.entryField.autocapitalizationType = UITextAutocapitalizationTypeNone; self.entryField.text = nil; break; } case MPActiveUserStateMasterPasswordConfirmation: { _masterPasswordChoice = self.entryField.text; self.entryLabel.text = strl( @"Confirm your master password:" ); self.entryField.secureTextEntry = YES; self.entryField.autocapitalizationType = UITextAutocapitalizationTypeNone; self.entryField.text = nil; break; } case MPActiveUserStateMinimized: break; } // Manage the entry container depending on whether a user is activate or not. switch (activeUserState) { case MPActiveUserStateNone: { self.avatarCollectionView.scrollEnabled = YES; self.entryContainer.visible = NO; self.footerContainer.visible = YES; break; } case MPActiveUserStateLogin: case MPActiveUserStateUserName: case MPActiveUserStateMasterPasswordChoice: case MPActiveUserStateMasterPasswordConfirmation: { self.avatarCollectionView.scrollEnabled = NO; self.entryContainer.visible = YES; self.footerContainer.visible = YES; requestFirstResponder = YES; break; } case MPActiveUserStateMinimized: { self.avatarCollectionView.scrollEnabled = NO; self.entryContainer.visible = NO; self.footerContainer.visible = NO; break; } } // Manage tip visibility. switch (activeUserState) { case MPActiveUserStateNone: case MPActiveUserStateMasterPasswordConfirmation: case MPActiveUserStateLogin: { break; } case MPActiveUserStateUserName: { [self showTips:MPUsersAvatarTip]; break; } case MPActiveUserStateMasterPasswordChoice: { [self showEntryTip:strl( @"A short phrase makes a strong, memorable password." )]; break; } case MPActiveUserStateMinimized: { if (![[NSUserDefaults standardUserDefaults] boolForKey:@"tipped.passwordsPreferences"]) [self showTips:MPUsersPreferencesTip]; break; } } [self.view layoutIfNeeded]; } completion:^(BOOL finished) { [_afterUpdates setSuspended:NO]; }]; [self.entryField resignFirstResponder]; if (requestFirstResponder) [self.entryField becomeFirstResponder]; // Set avatar modes. MPAvatarCell *selectedAvatar = [self selectedAvatar]; for (NSIndexPath *indexPath in [self.avatarCollectionView indexPathsForVisibleItems]) { MPAvatarCell *avatarCell = (MPAvatarCell *)[self.avatarCollectionView cellForItemAtIndexPath:indexPath]; [self updateModeForAvatar:avatarCell atIndexPath:indexPath animated:animated]; [self updateVisibilityForAvatar:avatarCell atIndexPath:indexPath animated:animated]; if (selectedAvatar && avatarCell == selectedAvatar) [self.avatarCollectionView scrollToItemAtIndexPath:indexPath atScrollPosition:UICollectionViewScrollPositionCenteredHorizontally animated:YES]; } } #pragma mark - Actions - (IBAction)changeAvatar:(UIButton *)sender { if (sender == self.previousAvatarButton) --[self selectedAvatar].avatar; if (sender == self.nextAvatarButton) ++[self selectedAvatar].avatar; } @end