916 lines
38 KiB
Objective-C
916 lines
38 KiB
Objective-C
//==============================================================================
|
|
// 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 <http://www.gnu.org/licenses/>.
|
|
//==============================================================================
|
|
|
|
#import <Countly/Countly.h>
|
|
|
|
#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;
|
|
@property(nonatomic, copy) NSString *masterPasswordChoice;
|
|
@property(nonatomic, strong) NSOperationQueue *afterUpdates;
|
|
@property(nonatomic, weak) id contextChangedObserver;
|
|
|
|
@end
|
|
|
|
@implementation MPUsersViewController
|
|
|
|
- (void)viewDidLoad {
|
|
|
|
[super viewDidLoad];
|
|
|
|
self.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)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<UIViewControllerTransitionCoordinator>)coordinator {
|
|
|
|
[self.avatarCollectionView.collectionViewLayout invalidateLayout];
|
|
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
|
|
}
|
|
|
|
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
|
|
|
|
if ([segue.identifier isEqualToString:@"thanks"])
|
|
((MPWebViewController *)segue.destinationViewController).initialURL =
|
|
[NSURL URLWithString:@"https://thanks.lhunath.com"];
|
|
}
|
|
|
|
- (void)viewWillDisappear:(BOOL)animated {
|
|
|
|
[super viewWillDisappear:animated];
|
|
|
|
[self removeObservers];
|
|
|
|
[self.marqueeTipTimer invalidate];
|
|
}
|
|
|
|
#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:self.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;
|
|
|
|
if ([[MPConfig get].sendInfo boolValue]) {
|
|
[Countly.sharedInstance recordEvent:@"new-user" segmentation:@{
|
|
@"algorithm": @(user.algorithm.version).description,
|
|
@"avatar" : @(user.avatar).description,
|
|
}];
|
|
}
|
|
}
|
|
|
|
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.text lowercaseString] isEqualToString:@"hangtest"])
|
|
[NSThread sleepForTimeInterval:10];
|
|
|
|
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.permanentObjectID 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];
|
|
if (isNew || !user)
|
|
return;
|
|
|
|
NSManagedObjectID *userID = user.permanentObjectID;
|
|
UIAlertController *controller = [UIAlertController alertControllerWithTitle:user.name message:nil preferredStyle:UIAlertControllerStyleActionSheet];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Delete User" style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) {
|
|
UIAlertController *controller = [UIAlertController alertControllerWithTitle:@"Deleting User" message:
|
|
@"The user and its sites will be deleted." preferredStyle:UIAlertControllerStyleAlert];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Delete User" style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) {
|
|
[self deleteUser:userID];
|
|
}]];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
|
[self presentViewController:controller animated:YES completion:nil];
|
|
}]];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Reset Password" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
|
|
UIAlertController *controller = [UIAlertController alertControllerWithTitle:@"Resetting User" message:
|
|
@"The user's master password will be reset." preferredStyle:UIAlertControllerStyleAlert];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Reset User" style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) {
|
|
[self resetUser:userID avatar:avatarCell];
|
|
}]];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
|
[self presentViewController:controller animated:YES completion:nil];
|
|
}]];
|
|
[controller addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
|
[self presentViewController:controller animated:YES completion: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];
|
|
}];
|
|
}
|
|
|
|
- (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 {
|
|
|
|
[self.afterUpdates addOperationWithBlock:^{
|
|
PearlMainQueue( block );
|
|
}];
|
|
}
|
|
|
|
- (void)removeObservers {
|
|
|
|
[self removeKeyPathObservers];
|
|
PearlRemoveNotificationObservers();
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self.contextChangedObserver];
|
|
}
|
|
|
|
- (void)registerObservers {
|
|
|
|
[self removeObservers];
|
|
[self observeKeyPath:@"avatarCollectionView.contentOffset" withBlock:
|
|
^(id from, id to, NSKeyValueChange cause, MPUsersViewController *self) {
|
|
PearlMainQueue( ^{ [self updateAvatarVisibility]; } );
|
|
}];
|
|
|
|
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];
|
|
} );
|
|
|
|
if ((self.contextChangedObserver
|
|
= [[MPiOSAppDelegate get] managedObjectContextChanged:^(NSDictionary<NSManagedObjectID *, NSString *> *affectedObjects) {
|
|
if ([[[affectedObjects allKeys] filteredArrayUsingPredicate:
|
|
[NSPredicate predicateWithBlock:^BOOL(NSManagedObjectID *objectID, NSDictionary *bindings) {
|
|
return [objectID.entity.name isEqualToString:NSStringFromClass( [MPUserEntity class] )];
|
|
}]] count])
|
|
[self reloadUsers];
|
|
}]))
|
|
[UIView animateWithDuration:0.3f animations:^{
|
|
self.avatarCollectionView.visible = YES;
|
|
[self.storeLoadingActivity stopAnimating];
|
|
}];
|
|
else
|
|
[UIView animateWithDuration:0.3f animations:^{
|
|
self.avatarCollectionView.visible = NO;
|
|
[self.storeLoadingActivity startAnimating];
|
|
}];
|
|
|
|
PearlAddNotificationObserver( NSPersistentStoreCoordinatorStoresWillChangeNotification, [MPiOSAppDelegate get].storeCoordinator, nil,
|
|
^(MPUsersViewController *self, NSNotification *note) {
|
|
self.userIDs = nil;
|
|
} );
|
|
PearlAddNotificationObserver( NSPersistentStoreCoordinatorStoresDidChangeNotification, [MPiOSAppDelegate get].storeCoordinator, nil,
|
|
^(MPUsersViewController *self, NSNotification *note) {
|
|
[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) {
|
|
MPError( error, @"Failed to load users." );
|
|
self.userIDs = nil;
|
|
}
|
|
|
|
NSMutableArray *userIDs = [NSMutableArray arrayWithCapacity:[users count]];
|
|
for (MPUserEntity *user in users)
|
|
[userIDs addObject:user.permanentObjectID];
|
|
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].permanentObjectID;
|
|
[self.avatarCollectionView reloadData];
|
|
|
|
NSUInteger selectedAvatarItem = isNew? [self.userIDs count]: selectUserID? [self.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;
|
|
self.masterPasswordChoice = nil;
|
|
|
|
if (activeUserState != MPActiveUserStateMinimized && (!self.active || [MPiOSAppDelegate get].activeUserOID)) {
|
|
[[MPiOSAppDelegate get] signOutAnimated:YES];
|
|
return;
|
|
}
|
|
|
|
// Set the entry container's contents.
|
|
[self.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: {
|
|
self.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) {
|
|
[self.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
|