// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "ProfileViewController.h" #import "AppDelegate.h" #import "AvatarViewHelper.h" #import "HomeViewController.h" #import "OWSNavigationController.h" #import "Signal-Swift.h" #import "SignalsNavigationController.h" #import "UIColor+OWS.h" #import "UIFont+OWS.h" #import "UIView+OWS.h" #import #import #import #import #import #import NS_ASSUME_NONNULL_BEGIN typedef NS_ENUM(NSInteger, ProfileViewMode) { ProfileViewMode_AppSettings = 0, ProfileViewMode_Registration, ProfileViewMode_UpgradeOrNag, }; NSString *const kProfileView_Collection = @"kProfileView_Collection"; NSString *const kProfileView_LastPresentedDate = @"kProfileView_LastPresentedDate"; @interface ProfileViewController () @property (nonatomic, readonly) AvatarViewHelper *avatarViewHelper; @property (nonatomic) UITextField *nameTextField; @property (nonatomic) AvatarImageView *avatarView; @property (nonatomic) UIImageView *cameraImageView; @property (nonatomic) OWSFlatButton *saveButton; @property (nonatomic, nullable) UIImage *avatar; @property (nonatomic) BOOL hasUnsavedChanges; @property (nonatomic) ProfileViewMode profileViewMode; @end #pragma mark - @implementation ProfileViewController - (instancetype)initWithMode:(ProfileViewMode)profileViewMode { self = [super init]; if (!self) { return self; } self.profileViewMode = profileViewMode; // Use the OWSPrimaryStorage.dbReadWriteConnection for consistency with the reads below. [[[OWSPrimaryStorage sharedManager] dbReadWriteConnection] setDate:[NSDate new] forKey:kProfileView_LastPresentedDate inCollection:kProfileView_Collection]; return self; } - (void)loadView { [super loadView]; self.title = NSLocalizedString(@"PROFILE_VIEW_TITLE", @"Title for the profile view."); _avatarViewHelper = [AvatarViewHelper new]; _avatarViewHelper.delegate = self; _avatar = [OWSProfileManager.sharedManager localProfileAvatarImage]; [self createViews]; [self updateNavigationItem]; } - (void)createViews { self.view.backgroundColor = Theme.offBackgroundColor; UIView *contentView = [UIView containerView]; contentView.backgroundColor = Theme.backgroundColor; [self.view addSubview:contentView]; [contentView autoPinToTopLayoutGuideOfViewController:self withInset:0]; [contentView autoPinWidthToSuperview]; const CGFloat fontSizePoints = ScaleFromIPhone5To7Plus(16.f, 20.f); NSMutableArray *rows = [NSMutableArray new]; // Name UIView *nameRow = [UIView containerView]; nameRow.userInteractionEnabled = YES; [nameRow addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(nameRowTapped:)]]; [rows addObject:nameRow]; UILabel *nameLabel = [UILabel new]; nameLabel.text = NSLocalizedString( @"PROFILE_VIEW_PROFILE_NAME_FIELD", @"Label for the profile name field of the profile view."); nameLabel.textColor = Theme.primaryColor; nameLabel.font = [UIFont ows_mediumFontWithSize:fontSizePoints]; [nameRow addSubview:nameLabel]; [nameLabel autoPinLeadingToSuperviewMargin]; [nameLabel autoPinHeightToSuperviewWithMargin:5.f]; UITextField *nameTextField; if (UIDevice.currentDevice.isShorterThanIPhone5) { nameTextField = [DismissableTextField new]; } else { nameTextField = [OWSTextField new]; } _nameTextField = nameTextField; nameTextField.font = [UIFont ows_mediumFontWithSize:18.f]; nameTextField.textColor = [UIColor ows_materialBlueColor]; nameTextField.placeholder = NSLocalizedString( @"PROFILE_VIEW_NAME_DEFAULT_TEXT", @"Default text for the profile name field of the profile view."); nameTextField.delegate = self; nameTextField.text = [OWSProfileManager.sharedManager localProfileName]; nameTextField.textAlignment = NSTextAlignmentRight; nameTextField.font = [UIFont ows_mediumFontWithSize:fontSizePoints]; [nameTextField addTarget:self action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged]; [nameRow addSubview:nameTextField]; [nameTextField autoPinLeadingToTrailingEdgeOfView:nameLabel offset:10.f]; [nameTextField autoPinTrailingToSuperviewMargin]; [nameTextField autoVCenterInSuperview]; // Avatar UIView *avatarRow = [UIView containerView]; avatarRow.userInteractionEnabled = YES; [avatarRow addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(avatarRowTapped:)]]; [rows addObject:avatarRow]; UILabel *avatarLabel = [UILabel new]; avatarLabel.text = NSLocalizedString( @"PROFILE_VIEW_PROFILE_AVATAR_FIELD", @"Label for the profile avatar field of the profile view."); avatarLabel.textColor = Theme.primaryColor; avatarLabel.font = [UIFont ows_mediumFontWithSize:fontSizePoints]; [avatarRow addSubview:avatarLabel]; [avatarLabel autoPinLeadingToSuperviewMargin]; [avatarLabel autoVCenterInSuperview]; self.avatarView = [AvatarImageView new]; UIImage *cameraImage = [UIImage imageNamed:@"settings-avatar-camera"]; self.cameraImageView = [[UIImageView alloc] initWithImage:cameraImage]; [avatarRow addSubview:self.avatarView]; [avatarRow addSubview:self.cameraImageView]; [self updateAvatarView]; [self.avatarView autoPinTrailingToSuperviewMargin]; [self.avatarView autoPinLeadingToTrailingEdgeOfView:avatarLabel offset:10.f]; const CGFloat kAvatarVMargin = 4.f; [self.avatarView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:kAvatarVMargin]; [self.avatarView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:kAvatarVMargin]; [self.avatarView autoSetDimension:ALDimensionWidth toSize:self.avatarSize]; [self.avatarView autoSetDimension:ALDimensionHeight toSize:self.avatarSize]; [self.cameraImageView autoPinTrailingToEdgeOfView:self.avatarView]; [self.cameraImageView autoPinEdge:ALEdgeBottom toEdge:ALEdgeBottom ofView:self.avatarView]; // Information UIView *infoRow = [UIView containerView]; infoRow.userInteractionEnabled = YES; [infoRow addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(infoRowTapped:)]]; [rows addObject:infoRow]; UILabel *infoLabel = [UILabel new]; infoLabel.textColor = Theme.secondaryColor; infoLabel.font = [UIFont ows_regularFontWithSize:11.f]; infoLabel.textAlignment = NSTextAlignmentCenter; NSMutableAttributedString *text = [NSMutableAttributedString new]; [text appendAttributedString:[[NSAttributedString alloc] initWithString:NSLocalizedString(@"PROFILE_VIEW_PROFILE_DESCRIPTION", @"Description of the user profile.") attributes:@{}]]; [text appendAttributedString:[[NSAttributedString alloc] initWithString:@" " attributes:@{}]]; [text appendAttributedString:[[NSAttributedString alloc] initWithString:NSLocalizedString(@"PROFILE_VIEW_PROFILE_DESCRIPTION_LINK", @"Link to more information about the user profile.") attributes:@{ NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle | NSUnderlinePatternSolid), NSForegroundColorAttributeName : [UIColor ows_materialBlueColor], }]]; infoLabel.attributedText = text; infoLabel.numberOfLines = 0; infoLabel.lineBreakMode = NSLineBreakByWordWrapping; [infoRow addSubview:infoLabel]; [infoLabel autoPinLeadingToSuperviewMargin]; [infoLabel autoPinTrailingToSuperviewMargin]; [infoLabel autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:10.f]; [infoLabel autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:10.f]; // Big Button if (self.profileViewMode == ProfileViewMode_Registration || self.profileViewMode == ProfileViewMode_UpgradeOrNag) { UIView *buttonRow = [UIView containerView]; [rows addObject:buttonRow]; const CGFloat kButtonHeight = 47.f; // NOTE: We use ows_signalBrandBlueColor instead of ows_materialBlueColor // throughout the onboarding flow to be consistent with the headers. OWSFlatButton *saveButton = [OWSFlatButton buttonWithTitle:NSLocalizedString(@"PROFILE_VIEW_SAVE_BUTTON", @"Button to save the profile view in the profile view.") font:[OWSFlatButton fontForHeight:kButtonHeight] titleColor:[UIColor whiteColor] backgroundColor:[UIColor ows_signalBrandBlueColor] target:self selector:@selector(saveButtonPressed)]; self.saveButton = saveButton; [buttonRow addSubview:saveButton]; [saveButton autoPinLeadingAndTrailingToSuperviewMargin]; [saveButton autoPinHeightToSuperview]; [saveButton autoSetDimension:ALDimensionHeight toSize:47.f]; } // Row Layout UIView *_Nullable lastRow = nil; for (UIView *row in rows) { [contentView addSubview:row]; if (lastRow) { [row autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastRow withOffset:5.f]; } else { [row autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:15.f]; } [row autoPinLeadingToSuperviewMarginWithInset:18.f]; [row autoPinTrailingToSuperviewMarginWithInset:18.f]; lastRow = row; if (lastRow == nameRow || lastRow == avatarRow) { UIView *separator = [UIView containerView]; separator.backgroundColor = Theme.cellSeparatorColor; [contentView addSubview:separator]; [separator autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastRow withOffset:5.f]; [separator autoPinLeadingToSuperviewMarginWithInset:18.f]; [separator autoPinTrailingToSuperviewMarginWithInset:18.f]; [separator autoSetDimension:ALDimensionHeight toSize:CGHairlineWidth()]; lastRow = separator; } } [lastRow autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:10.f]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self.nameTextField becomeFirstResponder]; } #pragma mark - Event Handling - (void)backOrSkipButtonPressed { [self leaveViewCheckingForUnsavedChanges]; } - (void)leaveViewCheckingForUnsavedChanges { [self.nameTextField resignFirstResponder]; if (!self.hasUnsavedChanges) { // If user made no changes, return to conversation settings view. [self profileCompletedOrSkipped]; return; } UIAlertController *controller = [UIAlertController alertControllerWithTitle: NSLocalizedString(@"NEW_GROUP_VIEW_UNSAVED_CHANGES_TITLE", @"The alert title if user tries to exit the new group view without saving changes.") message: NSLocalizedString(@"NEW_GROUP_VIEW_UNSAVED_CHANGES_MESSAGE", @"The alert message if user tries to exit the new group view without saving changes.") preferredStyle:UIAlertControllerStyleAlert]; [controller addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"ALERT_DISCARD_BUTTON", @"The label for the 'discard' button in alerts and action sheets.") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *action) { [self profileCompletedOrSkipped]; }]]; [controller addAction:[OWSAlerts cancelAction]]; [self presentViewController:controller animated:YES completion:nil]; } - (void)avatarTapped { [self.avatarViewHelper showChangeAvatarUI]; } - (void)setHasUnsavedChanges:(BOOL)hasUnsavedChanges { _hasUnsavedChanges = hasUnsavedChanges; [self updateNavigationItem]; } - (void)updateNavigationItem { // The navigation bar is hidden in the registration workflow. if (self.navigationController.navigationBarHidden) { [self.navigationController setNavigationBarHidden:NO animated:YES]; } // Always display a left item to leave the view without making changes. // This might be a "back", "skip" or "cancel" button depending on the // context. switch (self.profileViewMode) { case ProfileViewMode_AppSettings: if (self.hasUnsavedChanges) { // If we have a unsaved changes, right item should be a "save" button. self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSave target:self action:@selector(updatePressed)]; } else { self.navigationItem.rightBarButtonItem = nil; } break; case ProfileViewMode_UpgradeOrNag: case ProfileViewMode_Registration: self.navigationItem.hidesBackButton = YES; self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"NAVIGATION_ITEM_SKIP_BUTTON", @"A button to skip a view.") style:UIBarButtonItemStylePlain target:self action:@selector(backOrSkipButtonPressed)]; break; } // The save button is only used in "registration" and "upgrade or nag" modes. if (self.hasUnsavedChanges) { self.saveButton.enabled = YES; [self.saveButton setBackgroundColorsWithUpColor:[UIColor ows_signalBrandBlueColor]]; } else { self.saveButton.enabled = NO; [self.saveButton setBackgroundColorsWithUpColor:[[UIColor ows_signalBrandBlueColor] blendWithColor:Theme.backgroundColor alpha:0.5f]]; } } - (void)updatePressed { [self updateProfile]; } - (void)updateProfile { __weak ProfileViewController *weakSelf = self; NSString *normalizedProfileName = [self normalizedProfileName]; if ([OWSProfileManager.sharedManager isProfileNameTooLong:normalizedProfileName]) { [OWSAlerts showErrorAlertWithMessage:NSLocalizedString(@"PROFILE_VIEW_ERROR_PROFILE_NAME_TOO_LONG", @"Error message shown when user tries to update profile with a profile name " @"that is too long.")]; return; } // Show an activity indicator to block the UI during the profile upload. UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"PROFILE_VIEW_SAVING", @"Alert title that indicates the user's profile view is being saved.") message:nil preferredStyle:UIAlertControllerStyleAlert]; [self presentViewController:alertController animated:YES completion:^{ [OWSProfileManager.sharedManager updateLocalProfileName:normalizedProfileName avatarImage:self.avatar success:^{ [alertController dismissViewControllerAnimated:NO completion:^{ [weakSelf updateProfileCompleted]; }]; } failure:^{ [alertController dismissViewControllerAnimated:NO completion:^{ [OWSAlerts showErrorAlertWithMessage: NSLocalizedString( @"PROFILE_VIEW_ERROR_UPDATE_FAILED", @"Error message shown when a " @"profile update fails.")]; }]; }]; }]; } - (NSString *)normalizedProfileName { return [self.nameTextField.text ows_stripped]; } - (void)updateProfileCompleted { [self profileCompletedOrSkipped]; } - (void)profileCompletedOrSkipped { // Dismiss this view. switch (self.profileViewMode) { case ProfileViewMode_AppSettings: [self.navigationController popViewControllerAnimated:YES]; break; case ProfileViewMode_Registration: [self showHomeView]; break; case ProfileViewMode_UpgradeOrNag: [self dismissViewControllerAnimated:YES completion:nil]; break; } } - (void)showHomeView { HomeViewController *homeView = [HomeViewController new]; SignalsNavigationController *navigationController = [[SignalsNavigationController alloc] initWithRootViewController:homeView]; AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate; appDelegate.window.rootViewController = navigationController; OWSAssertDebug([navigationController.topViewController isKindOfClass:[HomeViewController class]]); } #pragma mark - UITextFieldDelegate - (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)editingRange replacementString:(NSString *)insertionText { // TODO: Possibly filter invalid input. return [TextFieldHelper textField:textField shouldChangeCharactersInRange:editingRange replacementString:insertionText byteLimit:kOWSProfileManager_NameDataLength]; } - (BOOL)textFieldShouldReturn:(UITextField *)textField { [self updateProfile]; return NO; } - (void)textFieldDidChange:(id)sender { self.hasUnsavedChanges = YES; // TODO: Update length warning. } #pragma mark - Avatar - (void)setAvatar:(nullable UIImage *)avatar { OWSAssertIsOnMainThread(); _avatar = avatar; self.hasUnsavedChanges = YES; [self updateAvatarView]; } - (NSUInteger)avatarSize { return 48; } - (void)updateAvatarView { self.avatarView.image = (self.avatar ?: [[[OWSContactAvatarBuilder alloc] initForLocalUserWithDiameter:self.avatarSize] buildDefaultImage]); self.cameraImageView.hidden = self.avatar != nil; } - (void)nameRowTapped:(UIGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateRecognized) { [self.nameTextField becomeFirstResponder]; } } - (void)avatarRowTapped:(UIGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateRecognized) { [self avatarTapped]; } } - (void)infoRowTapped:(UIGestureRecognizer *)sender { if (sender.state == UIGestureRecognizerStateRecognized) { [UIApplication.sharedApplication openURL:[NSURL URLWithString:@"https://support.signal.org/hc/en-us/articles/115001110511"]]; } } - (void)saveButtonPressed { [self updatePressed]; } #pragma mark - AvatarViewHelperDelegate + (BOOL)shouldDisplayProfileViewOnLaunch { // Only nag until the user sets a profile _name_. Profile names are // recommended; profile avatars are optional. if ([OWSProfileManager sharedManager].localProfileName.length > 0) { return NO; } // Use the OWSPrimaryStorage.dbReadWriteConnection for consistency with the writes above. NSTimeInterval kProfileNagFrequency = kDayInterval * 30; NSDate *_Nullable lastPresentedDate = [[[OWSPrimaryStorage sharedManager] dbReadWriteConnection] dateForKey:kProfileView_LastPresentedDate inCollection:kProfileView_Collection]; return (!lastPresentedDate || fabs([lastPresentedDate timeIntervalSinceNow]) > kProfileNagFrequency); } + (void)presentForAppSettings:(UINavigationController *)navigationController { OWSAssertDebug(navigationController); OWSAssertDebug([navigationController isKindOfClass:[OWSNavigationController class]]); ProfileViewController *vc = [[ProfileViewController alloc] initWithMode:ProfileViewMode_AppSettings]; [navigationController pushViewController:vc animated:YES]; } + (void)presentForRegistration:(UINavigationController *)navigationController { OWSAssertDebug(navigationController); OWSAssertDebug([navigationController isKindOfClass:[OWSNavigationController class]]); ProfileViewController *vc = [[ProfileViewController alloc] initWithMode:ProfileViewMode_Registration]; [navigationController pushViewController:vc animated:YES]; } + (void)presentForUpgradeOrNag:(HomeViewController *)fromViewController { OWSAssertDebug(fromViewController); ProfileViewController *vc = [[ProfileViewController alloc] initWithMode:ProfileViewMode_UpgradeOrNag]; OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:vc]; [fromViewController presentViewController:navigationController animated:YES completion:nil]; } #pragma mark - AvatarViewHelperDelegate - (NSString *)avatarActionSheetTitle { return NSLocalizedString( @"PROFILE_VIEW_AVATAR_ACTIONSHEET_TITLE", @"Action Sheet title prompting the user for a profile avatar"); } - (void)avatarDidChange:(UIImage *)image { OWSAssertIsOnMainThread(); OWSAssertDebug(image); self.avatar = [image resizedImageToFillPixelSize:CGSizeMake(kOWSProfileManager_MaxAvatarDiameter, kOWSProfileManager_MaxAvatarDiameter)]; } - (UIViewController *)fromViewController { return self; } - (BOOL)hasClearAvatarAction { return YES; } - (NSString *)clearAvatarActionLabel { return NSLocalizedString(@"PROFILE_VIEW_CLEAR_AVATAR", @"Label for action that clear's the user's profile avatar"); } - (void)clearAvatar { self.avatar = nil; } #pragma mark - OWSNavigationView - (BOOL)shouldCancelNavigationBack { BOOL result = self.hasUnsavedChanges; if (result) { [self backOrSkipButtonPressed]; } return result; } @end NS_ASSUME_NONNULL_END