2017-07-31 22:45:06 +02:00
|
|
|
//
|
2018-03-01 20:42:54 +01:00
|
|
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
2017-07-31 22:45:06 +02:00
|
|
|
//
|
|
|
|
|
|
|
|
#import "ProfileViewController.h"
|
2017-08-15 23:55:07 +02:00
|
|
|
#import "AppDelegate.h"
|
2017-07-31 23:12:09 +02:00
|
|
|
#import "AvatarViewHelper.h"
|
2017-09-06 19:59:39 +02:00
|
|
|
#import "HomeViewController.h"
|
2017-08-17 18:37:21 +02:00
|
|
|
#import "OWSNavigationController.h"
|
2017-07-31 22:45:06 +02:00
|
|
|
#import "Signal-Swift.h"
|
2017-08-15 23:55:07 +02:00
|
|
|
#import "SignalsNavigationController.h"
|
2017-07-31 22:45:06 +02:00
|
|
|
#import "UIColor+OWS.h"
|
|
|
|
#import "UIFont+OWS.h"
|
|
|
|
#import "UIView+OWS.h"
|
2018-09-21 21:41:10 +02:00
|
|
|
#import <SignalCoreKit/NSDate+OWS.h>
|
2017-12-19 03:50:51 +01:00
|
|
|
#import <SignalMessaging/NSString+OWS.h>
|
2018-05-17 04:42:00 +02:00
|
|
|
#import <SignalMessaging/OWSNavigationController.h>
|
2017-12-04 16:35:47 +01:00
|
|
|
#import <SignalMessaging/OWSProfileManager.h>
|
2018-03-01 20:42:54 +01:00
|
|
|
#import <SignalMessaging/UIViewController+OWS.h>
|
2018-03-05 15:30:58 +01:00
|
|
|
#import <SignalServiceKit/OWSPrimaryStorage.h>
|
2017-07-31 22:45:06 +02:00
|
|
|
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
typedef NS_ENUM(NSInteger, ProfileViewMode) {
|
|
|
|
ProfileViewMode_AppSettings = 0,
|
|
|
|
ProfileViewMode_Registration,
|
|
|
|
ProfileViewMode_UpgradeOrNag,
|
|
|
|
};
|
|
|
|
|
|
|
|
NSString *const kProfileView_Collection = @"kProfileView_Collection";
|
|
|
|
NSString *const kProfileView_LastPresentedDate = @"kProfileView_LastPresentedDate";
|
|
|
|
|
2017-08-17 18:37:21 +02:00
|
|
|
@interface ProfileViewController () <UITextFieldDelegate, AvatarViewHelperDelegate, OWSNavigationView>
|
2017-07-31 22:45:06 +02:00
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
@property (nonatomic, readonly) AvatarViewHelper *avatarViewHelper;
|
2017-07-31 22:45:06 +02:00
|
|
|
|
|
|
|
@property (nonatomic) UITextField *nameTextField;
|
|
|
|
|
|
|
|
@property (nonatomic) AvatarImageView *avatarView;
|
|
|
|
|
2017-08-14 22:18:54 +02:00
|
|
|
@property (nonatomic) UIImageView *cameraImageView;
|
|
|
|
|
2017-09-12 16:40:23 +02:00
|
|
|
@property (nonatomic) OWSFlatButton *saveButton;
|
2017-08-21 21:00:27 +02:00
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
@property (nonatomic, nullable) UIImage *avatar;
|
|
|
|
|
|
|
|
@property (nonatomic) BOOL hasUnsavedChanges;
|
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
@property (nonatomic) ProfileViewMode profileViewMode;
|
|
|
|
|
2017-07-31 22:45:06 +02:00
|
|
|
@end
|
|
|
|
|
|
|
|
#pragma mark -
|
|
|
|
|
|
|
|
@implementation ProfileViewController
|
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
- (instancetype)initWithMode:(ProfileViewMode)profileViewMode
|
|
|
|
{
|
|
|
|
self = [super init];
|
|
|
|
|
|
|
|
if (!self) {
|
|
|
|
return self;
|
|
|
|
}
|
|
|
|
|
|
|
|
self.profileViewMode = profileViewMode;
|
|
|
|
|
2018-03-05 15:30:58 +01:00
|
|
|
// Use the OWSPrimaryStorage.dbReadWriteConnection for consistency with the reads below.
|
|
|
|
[[[OWSPrimaryStorage sharedManager] dbReadWriteConnection] setDate:[NSDate new]
|
|
|
|
forKey:kProfileView_LastPresentedDate
|
|
|
|
inCollection:kProfileView_Collection];
|
2017-08-16 16:25:36 +02:00
|
|
|
|
|
|
|
return self;
|
|
|
|
}
|
|
|
|
|
2017-07-31 22:45:06 +02:00
|
|
|
- (void)loadView
|
|
|
|
{
|
|
|
|
[super loadView];
|
|
|
|
|
|
|
|
self.title = NSLocalizedString(@"PROFILE_VIEW_TITLE", @"Title for the profile view.");
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
_avatarViewHelper = [AvatarViewHelper new];
|
|
|
|
_avatarViewHelper.delegate = self;
|
2017-07-31 22:45:06 +02:00
|
|
|
|
2017-08-02 19:12:26 +02:00
|
|
|
_avatar = [OWSProfileManager.sharedManager localProfileAvatarImage];
|
2017-08-01 17:31:00 +02:00
|
|
|
|
2017-07-31 22:45:06 +02:00
|
|
|
[self createViews];
|
2017-08-15 23:55:07 +02:00
|
|
|
[self updateNavigationItem];
|
2018-10-29 17:59:56 +01:00
|
|
|
|
|
|
|
if (self.nameTextField.text.length > 0) {
|
|
|
|
self.hasUnsavedChanges = YES;
|
|
|
|
}
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (void)createViews
|
|
|
|
{
|
2018-08-08 15:47:54 +02:00
|
|
|
self.view.backgroundColor = Theme.offBackgroundColor;
|
2017-08-21 21:00:27 +02:00
|
|
|
|
|
|
|
UIView *contentView = [UIView containerView];
|
2018-08-08 15:37:23 +02:00
|
|
|
contentView.backgroundColor = Theme.backgroundColor;
|
2017-08-21 21:00:27 +02:00
|
|
|
[self.view addSubview:contentView];
|
2018-05-17 03:51:34 +02:00
|
|
|
[contentView autoPinToTopLayoutGuideOfViewController:self withInset:0];
|
2017-08-21 21:00:27 +02:00
|
|
|
[contentView autoPinWidthToSuperview];
|
2017-07-31 22:45:06 +02:00
|
|
|
|
2017-08-21 21:00:27 +02:00
|
|
|
const CGFloat fontSizePoints = ScaleFromIPhone5To7Plus(16.f, 20.f);
|
|
|
|
NSMutableArray<UIView *> *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.");
|
2018-08-08 15:37:23 +02:00
|
|
|
nameLabel.textColor = Theme.primaryColor;
|
2017-08-21 21:00:27 +02:00
|
|
|
nameLabel.font = [UIFont ows_mediumFontWithSize:fontSizePoints];
|
|
|
|
[nameRow addSubview:nameLabel];
|
2018-04-02 21:31:32 +02:00
|
|
|
[nameLabel autoPinLeadingToSuperviewMargin];
|
2017-08-21 21:00:27 +02:00
|
|
|
[nameLabel autoPinHeightToSuperviewWithMargin:5.f];
|
|
|
|
|
2018-07-06 01:36:52 +02:00
|
|
|
UITextField *nameTextField;
|
|
|
|
if (UIDevice.currentDevice.isShorterThanIPhone5) {
|
|
|
|
nameTextField = [DismissableTextField new];
|
|
|
|
} else {
|
2018-08-15 23:09:59 +02:00
|
|
|
nameTextField = [OWSTextField new];
|
2018-07-06 01:36:52 +02:00
|
|
|
}
|
2017-08-21 21:00:27 +02:00
|
|
|
_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];
|
2018-04-02 21:31:32 +02:00
|
|
|
[nameTextField autoPinLeadingToTrailingEdgeOfView:nameLabel offset:10.f];
|
|
|
|
[nameTextField autoPinTrailingToSuperviewMargin];
|
2017-08-21 21:00:27 +02:00
|
|
|
[nameTextField autoVCenterInSuperview];
|
|
|
|
|
|
|
|
// Avatar
|
|
|
|
|
|
|
|
UIView *avatarRow = [UIView containerView];
|
2017-08-21 21:09:53 +02:00
|
|
|
avatarRow.userInteractionEnabled = YES;
|
|
|
|
[avatarRow
|
|
|
|
addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(avatarRowTapped:)]];
|
2017-08-21 21:00:27 +02:00
|
|
|
[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.");
|
2018-08-08 15:37:23 +02:00
|
|
|
avatarLabel.textColor = Theme.primaryColor;
|
2017-08-21 21:00:27 +02:00
|
|
|
avatarLabel.font = [UIFont ows_mediumFontWithSize:fontSizePoints];
|
|
|
|
[avatarRow addSubview:avatarLabel];
|
2018-04-02 21:31:32 +02:00
|
|
|
[avatarLabel autoPinLeadingToSuperviewMargin];
|
2017-08-21 21:00:27 +02:00
|
|
|
[avatarLabel autoVCenterInSuperview];
|
|
|
|
|
|
|
|
self.avatarView = [AvatarImageView new];
|
2017-07-31 22:45:06 +02:00
|
|
|
|
2017-08-14 22:18:54 +02:00
|
|
|
UIImage *cameraImage = [UIImage imageNamed:@"settings-avatar-camera"];
|
2017-08-21 21:00:27 +02:00
|
|
|
self.cameraImageView = [[UIImageView alloc] initWithImage:cameraImage];
|
2017-08-21 18:22:12 +02:00
|
|
|
|
2017-08-21 21:00:27 +02:00
|
|
|
[avatarRow addSubview:self.avatarView];
|
|
|
|
[avatarRow addSubview:self.cameraImageView];
|
|
|
|
[self updateAvatarView];
|
2018-04-02 21:31:32 +02:00
|
|
|
[self.avatarView autoPinTrailingToSuperviewMargin];
|
|
|
|
[self.avatarView autoPinLeadingToTrailingEdgeOfView:avatarLabel offset:10.f];
|
2017-08-21 18:22:12 +02:00
|
|
|
const CGFloat kAvatarVMargin = 4.f;
|
2017-08-21 21:00:27 +02:00
|
|
|
[self.avatarView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:kAvatarVMargin];
|
|
|
|
[self.avatarView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:kAvatarVMargin];
|
2018-09-27 16:42:27 +02:00
|
|
|
[self.avatarView autoSetDimension:ALDimensionWidth toSize:self.avatarSize];
|
|
|
|
[self.avatarView autoSetDimension:ALDimensionHeight toSize:self.avatarSize];
|
2018-04-02 21:31:32 +02:00
|
|
|
[self.cameraImageView autoPinTrailingToEdgeOfView:self.avatarView];
|
2017-08-21 21:00:27 +02:00
|
|
|
[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];
|
2018-08-08 15:37:23 +02:00
|
|
|
infoLabel.textColor = Theme.secondaryColor;
|
2018-04-09 22:13:48 +02:00
|
|
|
infoLabel.font = [UIFont ows_regularFontWithSize:11.f];
|
2017-08-21 21:00:27 +02:00
|
|
|
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];
|
2018-04-02 21:31:32 +02:00
|
|
|
[infoLabel autoPinLeadingToSuperviewMargin];
|
|
|
|
[infoLabel autoPinTrailingToSuperviewMargin];
|
2017-08-21 21:00:27 +02:00
|
|
|
[infoLabel autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:10.f];
|
|
|
|
[infoLabel autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:10.f];
|
|
|
|
|
|
|
|
// Big Button
|
|
|
|
|
2017-08-23 19:31:06 +02:00
|
|
|
if (self.profileViewMode == ProfileViewMode_Registration || self.profileViewMode == ProfileViewMode_UpgradeOrNag) {
|
2017-08-21 21:00:27 +02:00
|
|
|
UIView *buttonRow = [UIView containerView];
|
|
|
|
[rows addObject:buttonRow];
|
|
|
|
|
2017-09-12 16:40:23 +02:00
|
|
|
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)];
|
2017-08-23 19:31:06 +02:00
|
|
|
self.saveButton = saveButton;
|
|
|
|
[buttonRow addSubview:saveButton];
|
2018-04-02 21:31:32 +02:00
|
|
|
[saveButton autoPinLeadingAndTrailingToSuperviewMargin];
|
2017-08-23 19:31:06 +02:00
|
|
|
[saveButton autoPinHeightToSuperview];
|
|
|
|
[saveButton autoSetDimension:ALDimensionHeight toSize:47.f];
|
2017-08-21 18:22:12 +02:00
|
|
|
}
|
2017-08-21 21:00:27 +02:00
|
|
|
|
|
|
|
// 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];
|
|
|
|
}
|
2018-04-02 21:31:32 +02:00
|
|
|
[row autoPinLeadingToSuperviewMarginWithInset:18.f];
|
|
|
|
[row autoPinTrailingToSuperviewMarginWithInset:18.f];
|
2017-08-21 21:00:27 +02:00
|
|
|
lastRow = row;
|
|
|
|
|
|
|
|
if (lastRow == nameRow || lastRow == avatarRow) {
|
|
|
|
UIView *separator = [UIView containerView];
|
2018-08-10 00:43:25 +02:00
|
|
|
separator.backgroundColor = Theme.cellSeparatorColor;
|
2018-08-08 15:47:54 +02:00
|
|
|
[contentView addSubview:separator];
|
2017-08-21 21:00:27 +02:00
|
|
|
[separator autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:lastRow withOffset:5.f];
|
2018-04-02 21:31:32 +02:00
|
|
|
[separator autoPinLeadingToSuperviewMarginWithInset:18.f];
|
|
|
|
[separator autoPinTrailingToSuperviewMarginWithInset:18.f];
|
2018-08-10 00:43:25 +02:00
|
|
|
[separator autoSetDimension:ALDimensionHeight toSize:CGHairlineWidth()];
|
2017-08-21 21:00:27 +02:00
|
|
|
lastRow = separator;
|
|
|
|
}
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
2017-08-21 21:00:27 +02:00
|
|
|
[lastRow autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:10.f];
|
|
|
|
}
|
2017-07-31 22:45:06 +02:00
|
|
|
|
2017-08-21 21:00:27 +02:00
|
|
|
- (void)viewWillAppear:(BOOL)animated
|
|
|
|
{
|
|
|
|
[super viewWillAppear:animated];
|
|
|
|
|
|
|
|
[self.nameTextField becomeFirstResponder];
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
#pragma mark - Event Handling
|
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
- (void)backOrSkipButtonPressed
|
2017-07-31 23:12:09 +02:00
|
|
|
{
|
2017-08-16 16:25:36 +02:00
|
|
|
[self leaveViewCheckingForUnsavedChanges];
|
2017-08-15 23:55:07 +02:00
|
|
|
}
|
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
- (void)leaveViewCheckingForUnsavedChanges
|
2017-08-15 23:55:07 +02:00
|
|
|
{
|
2017-07-31 23:12:09 +02:00
|
|
|
[self.nameTextField resignFirstResponder];
|
|
|
|
|
2017-08-18 16:02:45 +02:00
|
|
|
if (!self.hasUnsavedChanges) {
|
2017-07-31 23:12:09 +02:00
|
|
|
// If user made no changes, return to conversation settings view.
|
2017-08-16 16:25:36 +02:00
|
|
|
[self profileCompletedOrSkipped];
|
2017-07-31 23:12:09 +02:00
|
|
|
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) {
|
2017-08-16 16:25:36 +02:00
|
|
|
[self profileCompletedOrSkipped];
|
2017-07-31 23:12:09 +02:00
|
|
|
}]];
|
2017-08-21 22:58:26 +02:00
|
|
|
[controller addAction:[OWSAlerts cancelAction]];
|
2017-07-31 23:12:09 +02:00
|
|
|
[self presentViewController:controller animated:YES completion:nil];
|
|
|
|
}
|
2017-07-31 22:45:06 +02:00
|
|
|
|
2017-08-21 18:22:12 +02:00
|
|
|
- (void)avatarTapped
|
2017-07-31 22:45:06 +02:00
|
|
|
{
|
2017-08-21 18:22:12 +02:00
|
|
|
[self.avatarViewHelper showChangeAvatarUI];
|
2017-07-31 23:12:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (void)setHasUnsavedChanges:(BOOL)hasUnsavedChanges
|
|
|
|
{
|
|
|
|
_hasUnsavedChanges = hasUnsavedChanges;
|
|
|
|
|
2017-08-15 23:55:07 +02:00
|
|
|
[self updateNavigationItem];
|
|
|
|
}
|
|
|
|
|
|
|
|
- (void)updateNavigationItem
|
|
|
|
{
|
|
|
|
// The navigation bar is hidden in the registration workflow.
|
2017-08-16 16:25:36 +02:00
|
|
|
if (self.navigationController.navigationBarHidden) {
|
|
|
|
[self.navigationController setNavigationBarHidden:NO animated:YES];
|
|
|
|
}
|
2017-08-15 23:55:07 +02:00
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
// 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.
|
2017-08-15 23:55:07 +02:00
|
|
|
switch (self.profileViewMode) {
|
|
|
|
case ProfileViewMode_AppSettings:
|
2017-08-23 19:31:06 +02:00
|
|
|
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;
|
|
|
|
}
|
2017-08-15 23:55:07 +02:00
|
|
|
break;
|
|
|
|
case ProfileViewMode_UpgradeOrNag:
|
2017-08-16 16:25:36 +02:00
|
|
|
case ProfileViewMode_Registration:
|
2017-08-23 20:07:31 +02:00
|
|
|
self.navigationItem.hidesBackButton = YES;
|
2017-08-23 19:31:06 +02:00
|
|
|
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
|
2017-08-15 23:55:07 +02:00
|
|
|
initWithTitle:NSLocalizedString(@"NAVIGATION_ITEM_SKIP_BUTTON", @"A button to skip a view.")
|
|
|
|
style:UIBarButtonItemStylePlain
|
|
|
|
target:self
|
2017-08-16 16:25:36 +02:00
|
|
|
action:@selector(backOrSkipButtonPressed)];
|
2017-08-15 23:55:07 +02:00
|
|
|
break;
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
2017-08-21 21:03:26 +02:00
|
|
|
|
2017-08-23 19:31:06 +02:00
|
|
|
// The save button is only used in "registration" and "upgrade or nag" modes.
|
|
|
|
if (self.hasUnsavedChanges) {
|
|
|
|
self.saveButton.enabled = YES;
|
2017-09-18 21:47:59 +02:00
|
|
|
[self.saveButton setBackgroundColorsWithUpColor:[UIColor ows_signalBrandBlueColor]];
|
2017-08-21 21:03:26 +02:00
|
|
|
} else {
|
2017-08-23 19:31:06 +02:00
|
|
|
self.saveButton.enabled = NO;
|
2017-09-12 16:40:23 +02:00
|
|
|
[self.saveButton
|
2018-08-08 15:37:23 +02:00
|
|
|
setBackgroundColorsWithUpColor:[[UIColor ows_signalBrandBlueColor] blendWithColor:Theme.backgroundColor
|
2017-09-18 21:47:59 +02:00
|
|
|
alpha:0.5f]];
|
2017-08-15 23:55:07 +02:00
|
|
|
}
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
- (void)updatePressed
|
|
|
|
{
|
|
|
|
[self updateProfile];
|
|
|
|
}
|
|
|
|
|
|
|
|
- (void)updateProfile
|
|
|
|
{
|
2017-08-01 17:31:00 +02:00
|
|
|
__weak ProfileViewController *weakSelf = self;
|
2017-08-16 17:06:22 +02:00
|
|
|
|
2017-08-25 00:02:33 +02:00
|
|
|
NSString *normalizedProfileName = [self normalizedProfileName];
|
|
|
|
if ([OWSProfileManager.sharedManager isProfileNameTooLong:normalizedProfileName]) {
|
2018-03-06 14:29:25 +01:00
|
|
|
[OWSAlerts
|
|
|
|
showErrorAlertWithMessage:NSLocalizedString(@"PROFILE_VIEW_ERROR_PROFILE_NAME_TOO_LONG",
|
2017-08-25 00:02:33 +02:00
|
|
|
@"Error message shown when user tries to update profile with a profile name "
|
|
|
|
@"that is too long.")];
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2017-08-16 17:06:22 +02:00
|
|
|
// Show an activity indicator to block the UI during the profile upload.
|
2017-08-16 22:10:07 +02:00
|
|
|
UIAlertController *alertController = [UIAlertController
|
|
|
|
alertControllerWithTitle:NSLocalizedString(@"PROFILE_VIEW_SAVING",
|
|
|
|
@"Alert title that indicates the user's profile view is being saved.")
|
|
|
|
message:nil
|
|
|
|
preferredStyle:UIAlertControllerStyleAlert];
|
2017-08-16 17:06:22 +02:00
|
|
|
|
|
|
|
[self presentViewController:alertController
|
|
|
|
animated:YES
|
|
|
|
completion:^{
|
2017-08-25 00:02:33 +02:00
|
|
|
[OWSProfileManager.sharedManager updateLocalProfileName:normalizedProfileName
|
2017-08-16 17:06:22 +02:00
|
|
|
avatarImage:self.avatar
|
|
|
|
success:^{
|
2017-08-16 22:10:07 +02:00
|
|
|
[alertController dismissViewControllerAnimated:NO
|
|
|
|
completion:^{
|
2017-08-15 23:55:07 +02:00
|
|
|
[weakSelf updateProfileCompleted];
|
2017-08-16 22:10:07 +02:00
|
|
|
}];
|
2017-08-16 17:06:22 +02:00
|
|
|
}
|
|
|
|
failure:^{
|
2017-08-16 22:10:07 +02:00
|
|
|
[alertController
|
|
|
|
dismissViewControllerAnimated:NO
|
|
|
|
completion:^{
|
2018-03-06 14:29:25 +01:00
|
|
|
[OWSAlerts showErrorAlertWithMessage:
|
|
|
|
NSLocalizedString(
|
|
|
|
@"PROFILE_VIEW_ERROR_UPDATE_FAILED",
|
|
|
|
@"Error message shown when a "
|
|
|
|
@"profile update fails.")];
|
2017-08-16 22:10:07 +02:00
|
|
|
}];
|
2017-08-16 17:06:22 +02:00
|
|
|
}];
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
|
|
|
|
- (NSString *)normalizedProfileName
|
|
|
|
{
|
2017-10-18 20:53:31 +02:00
|
|
|
return [self.nameTextField.text ows_stripped];
|
2017-07-31 23:12:09 +02:00
|
|
|
}
|
|
|
|
|
2017-08-15 23:55:07 +02:00
|
|
|
- (void)updateProfileCompleted
|
|
|
|
{
|
|
|
|
[self profileCompletedOrSkipped];
|
|
|
|
}
|
|
|
|
|
|
|
|
- (void)profileCompletedOrSkipped
|
|
|
|
{
|
2017-08-16 16:25:36 +02:00
|
|
|
// Dismiss this view.
|
2017-08-15 23:55:07 +02:00
|
|
|
switch (self.profileViewMode) {
|
|
|
|
case ProfileViewMode_AppSettings:
|
|
|
|
[self.navigationController popViewControllerAnimated:YES];
|
|
|
|
break;
|
|
|
|
case ProfileViewMode_Registration:
|
2018-11-19 22:17:40 +01:00
|
|
|
if (![TSAccountManager sharedInstance].isReregistering) {
|
|
|
|
[self checkCanImportBackup];
|
|
|
|
return;
|
|
|
|
}
|
2017-08-15 23:55:07 +02:00
|
|
|
[self showHomeView];
|
|
|
|
break;
|
|
|
|
case ProfileViewMode_UpgradeOrNag:
|
|
|
|
[self dismissViewControllerAnimated:YES completion:nil];
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
- (void)showHomeView
|
|
|
|
{
|
2018-11-19 23:35:35 +01:00
|
|
|
[SignalApp.sharedApp showHomeView];
|
2017-08-15 23:55:07 +02:00
|
|
|
}
|
|
|
|
|
2018-11-19 22:17:40 +01:00
|
|
|
- (void)showBackupRestoreView
|
|
|
|
{
|
2018-11-19 23:35:35 +01:00
|
|
|
BackupRestoreViewController *restoreView = [BackupRestoreViewController new];
|
|
|
|
[self.navigationController setViewControllers:@[
|
|
|
|
restoreView,
|
|
|
|
]
|
|
|
|
animated:YES];
|
2018-11-19 22:17:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
- (void)checkCanImportBackup
|
|
|
|
{
|
|
|
|
[OWSBackup.sharedManager
|
|
|
|
checkCanImportBackup:^(BOOL value) {
|
|
|
|
OWSLogInfo(@"has backup available for import? %d", value);
|
|
|
|
|
|
|
|
if (value) {
|
|
|
|
[self showBackupRestoreView];
|
|
|
|
} else {
|
|
|
|
[self showHomeView];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
failure:^(NSError *error) {
|
|
|
|
UIAlertController *controller = [UIAlertController
|
|
|
|
alertControllerWithTitle:
|
|
|
|
NSLocalizedString(@"CHECK_FOR_BACKUP_FAILED_TITLE",
|
|
|
|
@"Title for alert shown when the app failed to check for an existing backup.")
|
|
|
|
message:NSLocalizedString(@"CHECK_FOR_BACKUP_FAILED_MESSAGE",
|
|
|
|
@"Message for alert shown when the app failed to check for an existing "
|
|
|
|
@"backup.")
|
|
|
|
preferredStyle:UIAlertControllerStyleAlert];
|
|
|
|
[controller addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"REGISTER_FAILED_TRY_AGAIN", nil)
|
|
|
|
style:UIAlertActionStyleDefault
|
|
|
|
handler:^(UIAlertAction *action) {
|
|
|
|
[self checkCanImportBackup];
|
|
|
|
}]];
|
|
|
|
[controller
|
|
|
|
addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"CHECK_FOR_BACKUP_DO_NOT_RESTORE",
|
|
|
|
@"The label for the 'do not restore backup' button.")
|
|
|
|
style:UIAlertActionStyleDestructive
|
|
|
|
handler:^(UIAlertAction *action) {
|
|
|
|
[self showHomeView];
|
|
|
|
}]];
|
|
|
|
[self presentViewController:controller animated:YES completion:nil];
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
|
2017-07-31 22:45:06 +02:00
|
|
|
#pragma mark - UITextFieldDelegate
|
|
|
|
|
|
|
|
- (BOOL)textField:(UITextField *)textField
|
2017-09-01 20:52:17 +02:00
|
|
|
shouldChangeCharactersInRange:(NSRange)editingRange
|
2017-07-31 22:45:06 +02:00
|
|
|
replacementString:(NSString *)insertionText
|
|
|
|
{
|
2017-08-01 17:34:23 +02:00
|
|
|
// TODO: Possibly filter invalid input.
|
2017-09-01 22:49:03 +02:00
|
|
|
return [TextFieldHelper textField:textField
|
|
|
|
shouldChangeCharactersInRange:editingRange
|
|
|
|
replacementString:insertionText
|
|
|
|
byteLimit:kOWSProfileManager_NameDataLength];
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (BOOL)textFieldShouldReturn:(UITextField *)textField
|
|
|
|
{
|
2017-08-01 17:34:23 +02:00
|
|
|
[self updateProfile];
|
|
|
|
return NO;
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (void)textFieldDidChange:(id)sender
|
|
|
|
{
|
2017-07-31 23:12:09 +02:00
|
|
|
self.hasUnsavedChanges = YES;
|
2017-08-01 17:34:23 +02:00
|
|
|
|
|
|
|
// TODO: Update length warning.
|
2017-07-31 22:45:06 +02:00
|
|
|
}
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
#pragma mark - Avatar
|
|
|
|
|
|
|
|
- (void)setAvatar:(nullable UIImage *)avatar
|
|
|
|
{
|
2017-12-19 17:38:25 +01:00
|
|
|
OWSAssertIsOnMainThread();
|
2017-07-31 23:12:09 +02:00
|
|
|
|
|
|
|
_avatar = avatar;
|
|
|
|
|
|
|
|
self.hasUnsavedChanges = YES;
|
|
|
|
|
|
|
|
[self updateAvatarView];
|
|
|
|
}
|
|
|
|
|
2018-09-27 16:42:27 +02:00
|
|
|
- (NSUInteger)avatarSize
|
|
|
|
{
|
|
|
|
return 48;
|
|
|
|
}
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
- (void)updateAvatarView
|
|
|
|
{
|
2017-08-14 22:18:54 +02:00
|
|
|
self.avatarView.image = (self.avatar
|
2018-09-27 16:42:27 +02:00
|
|
|
?: [[[OWSContactAvatarBuilder alloc] initForLocalUserWithDiameter:self.avatarSize] buildDefaultImage]);
|
2017-08-14 22:18:54 +02:00
|
|
|
self.cameraImageView.hidden = self.avatar != nil;
|
2017-07-31 23:12:09 +02:00
|
|
|
}
|
|
|
|
|
2017-08-21 21:00:27 +02:00
|
|
|
- (void)nameRowTapped:(UIGestureRecognizer *)sender
|
|
|
|
{
|
|
|
|
if (sender.state == UIGestureRecognizerStateRecognized) {
|
|
|
|
[self.nameTextField becomeFirstResponder];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-08-21 21:09:53 +02:00
|
|
|
- (void)avatarRowTapped:(UIGestureRecognizer *)sender
|
|
|
|
{
|
|
|
|
if (sender.state == UIGestureRecognizerStateRecognized) {
|
|
|
|
[self avatarTapped];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-08-21 21:00:27 +02:00
|
|
|
- (void)infoRowTapped:(UIGestureRecognizer *)sender
|
|
|
|
{
|
|
|
|
if (sender.state == UIGestureRecognizerStateRecognized) {
|
|
|
|
[UIApplication.sharedApplication
|
2017-09-08 18:56:53 +02:00
|
|
|
openURL:[NSURL URLWithString:@"https://support.signal.org/hc/en-us/articles/115001110511"]];
|
2017-08-21 21:00:27 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-08-23 19:31:06 +02:00
|
|
|
- (void)saveButtonPressed
|
2017-08-21 21:00:27 +02:00
|
|
|
{
|
2017-08-23 19:31:06 +02:00
|
|
|
[self updatePressed];
|
2017-08-21 21:00:27 +02:00
|
|
|
}
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
#pragma mark - AvatarViewHelperDelegate
|
|
|
|
|
2017-08-16 16:25:36 +02:00
|
|
|
+ (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;
|
|
|
|
}
|
|
|
|
|
2018-03-05 15:30:58 +01:00
|
|
|
// Use the OWSPrimaryStorage.dbReadWriteConnection for consistency with the writes above.
|
2017-08-16 16:25:36 +02:00
|
|
|
NSTimeInterval kProfileNagFrequency = kDayInterval * 30;
|
|
|
|
NSDate *_Nullable lastPresentedDate =
|
2018-03-05 15:30:58 +01:00
|
|
|
[[[OWSPrimaryStorage sharedManager] dbReadWriteConnection] dateForKey:kProfileView_LastPresentedDate
|
|
|
|
inCollection:kProfileView_Collection];
|
2017-08-16 16:25:36 +02:00
|
|
|
return (!lastPresentedDate || fabs([lastPresentedDate timeIntervalSinceNow]) > kProfileNagFrequency);
|
|
|
|
}
|
|
|
|
|
|
|
|
+ (void)presentForAppSettings:(UINavigationController *)navigationController
|
|
|
|
{
|
2018-09-06 19:01:24 +02:00
|
|
|
OWSAssertDebug(navigationController);
|
|
|
|
OWSAssertDebug([navigationController isKindOfClass:[OWSNavigationController class]]);
|
2017-08-16 16:25:36 +02:00
|
|
|
|
|
|
|
ProfileViewController *vc = [[ProfileViewController alloc] initWithMode:ProfileViewMode_AppSettings];
|
|
|
|
[navigationController pushViewController:vc animated:YES];
|
|
|
|
}
|
|
|
|
|
|
|
|
+ (void)presentForRegistration:(UINavigationController *)navigationController
|
|
|
|
{
|
2018-09-06 19:01:24 +02:00
|
|
|
OWSAssertDebug(navigationController);
|
|
|
|
OWSAssertDebug([navigationController isKindOfClass:[OWSNavigationController class]]);
|
2017-08-16 16:25:36 +02:00
|
|
|
|
|
|
|
ProfileViewController *vc = [[ProfileViewController alloc] initWithMode:ProfileViewMode_Registration];
|
|
|
|
[navigationController pushViewController:vc animated:YES];
|
|
|
|
}
|
|
|
|
|
Faster conversation presentation.
There are multiple places in the codebase we present a conversation.
We used to have some very conservative machinery around how this was done, for
fear of failing to present the call view controller, which would have left a
hidden call in the background. We've since addressed that concern more
thoroughly via the separate calling UIWindow.
As such, the remaining presentation machinery is overly complex and inflexible
for what we need.
Sometimes we want to animate-push the conversation. (tap on home, tap on "send message" in contact card/group members)
Sometimes we want to dismiss a modal, to reveal the conversation behind it (contact picker, group creation)
Sometimes we want to present the conversation with no animation (becoming active from a notification)
We also want to ensure that we're never pushing more than one conversation view
controller, which was previously a problem since we were "pushing" a newly
constructed VC in response to these myriad actions. It turned out there were
certain code paths that caused multiple actions to be fired in rapid succession
which pushed multiple ConversationVC's.
The built-in method: `setViewControllers:animated` easily ensures we only have
one ConversationVC on the stack, while being composable enough to faciliate the
various more efficient animations we desire.
The only thing lost with the complex methods is that the naive
`presentViewController:` can fail, e.g. if another view is already presented.
E.g. if an alert appears *just* before the user taps compose, the contact
picker will fail to present.
Since we no longer depend on this for presenting the CallViewController, this
isn't catostrophic, and in fact, arguable preferable, since we want the user to
read and dismiss any alert explicitly.
// FREEBIE
2018-08-18 22:54:35 +02:00
|
|
|
+ (void)presentForUpgradeOrNag:(HomeViewController *)fromViewController
|
2017-08-16 16:25:36 +02:00
|
|
|
{
|
2018-09-06 19:01:24 +02:00
|
|
|
OWSAssertDebug(fromViewController);
|
2017-08-16 16:25:36 +02:00
|
|
|
|
|
|
|
ProfileViewController *vc = [[ProfileViewController alloc] initWithMode:ProfileViewMode_UpgradeOrNag];
|
2017-08-17 18:37:21 +02:00
|
|
|
OWSNavigationController *navigationController = [[OWSNavigationController alloc] initWithRootViewController:vc];
|
Faster conversation presentation.
There are multiple places in the codebase we present a conversation.
We used to have some very conservative machinery around how this was done, for
fear of failing to present the call view controller, which would have left a
hidden call in the background. We've since addressed that concern more
thoroughly via the separate calling UIWindow.
As such, the remaining presentation machinery is overly complex and inflexible
for what we need.
Sometimes we want to animate-push the conversation. (tap on home, tap on "send message" in contact card/group members)
Sometimes we want to dismiss a modal, to reveal the conversation behind it (contact picker, group creation)
Sometimes we want to present the conversation with no animation (becoming active from a notification)
We also want to ensure that we're never pushing more than one conversation view
controller, which was previously a problem since we were "pushing" a newly
constructed VC in response to these myriad actions. It turned out there were
certain code paths that caused multiple actions to be fired in rapid succession
which pushed multiple ConversationVC's.
The built-in method: `setViewControllers:animated` easily ensures we only have
one ConversationVC on the stack, while being composable enough to faciliate the
various more efficient animations we desire.
The only thing lost with the complex methods is that the naive
`presentViewController:` can fail, e.g. if another view is already presented.
E.g. if an alert appears *just* before the user taps compose, the contact
picker will fail to present.
Since we no longer depend on this for presenting the CallViewController, this
isn't catostrophic, and in fact, arguable preferable, since we want the user to
read and dismiss any alert explicitly.
// FREEBIE
2018-08-18 22:54:35 +02:00
|
|
|
[fromViewController presentViewController:navigationController animated:YES completion:nil];
|
2017-08-16 16:25:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#pragma mark - AvatarViewHelperDelegate
|
|
|
|
|
2017-08-01 17:31:00 +02:00
|
|
|
- (NSString *)avatarActionSheetTitle
|
|
|
|
{
|
|
|
|
return NSLocalizedString(
|
2017-08-01 17:58:51 +02:00
|
|
|
@"PROFILE_VIEW_AVATAR_ACTIONSHEET_TITLE", @"Action Sheet title prompting the user for a profile avatar");
|
2017-08-01 17:31:00 +02:00
|
|
|
}
|
|
|
|
|
2017-07-31 23:12:09 +02:00
|
|
|
- (void)avatarDidChange:(UIImage *)image
|
|
|
|
{
|
2017-12-19 17:38:25 +01:00
|
|
|
OWSAssertIsOnMainThread();
|
2018-09-06 19:01:24 +02:00
|
|
|
OWSAssertDebug(image);
|
2017-07-31 23:12:09 +02:00
|
|
|
|
2017-08-15 21:40:46 +02:00
|
|
|
self.avatar = [image resizedImageToFillPixelSize:CGSizeMake(kOWSProfileManager_MaxAvatarDiameter,
|
|
|
|
kOWSProfileManager_MaxAvatarDiameter)];
|
2017-07-31 23:12:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (UIViewController *)fromViewController
|
|
|
|
{
|
|
|
|
return self;
|
|
|
|
}
|
|
|
|
|
2017-08-01 17:58:51 +02:00
|
|
|
- (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;
|
|
|
|
}
|
|
|
|
|
2017-08-17 18:37:21 +02:00
|
|
|
#pragma mark - OWSNavigationView
|
|
|
|
|
2017-08-18 15:58:16 +02:00
|
|
|
- (BOOL)shouldCancelNavigationBack
|
2017-08-17 18:37:21 +02:00
|
|
|
{
|
2017-08-18 16:02:45 +02:00
|
|
|
BOOL result = self.hasUnsavedChanges;
|
2017-08-18 15:58:16 +02:00
|
|
|
if (result) {
|
|
|
|
[self backOrSkipButtonPressed];
|
|
|
|
}
|
|
|
|
return result;
|
2017-08-17 18:37:21 +02:00
|
|
|
}
|
|
|
|
|
2017-07-31 22:45:06 +02:00
|
|
|
@end
|
|
|
|
|
|
|
|
NS_ASSUME_NONNULL_END
|