// _____ _
// |_ _| |_ _ _ ___ ___ _ __ __ _
// | | | ' \| '_/ -_) -_) ' \/ _` |_
// |_| |_||_|_| \___\___|_|_|_\__,_(_)
//
// Threema iOS Client
// Copyright (c) 2014-2020 Threema GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License, version 3,
// as published by the Free Software Foundation.
//
// This program 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 Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
#import "BallotCreateViewController.h"
#import "BallotCreateTableCell.h"
#import "BallotChoice.h"
#import "EntityManager.h"
#import "Ballot.h"
#import "MessageSender.h"
#import "AppDelegate.h"
#import "NaClCrypto.h"
#import "ProtocolDefines.h"
#import "MyIdentityStore.h"
#import "RectUtil.h"
#import "BallotCreateDetailViewController.h"
#import "BallotManager.h"
#import "ContactStore.h"
#import "Contact.h"
#import "AppGroup.h"
#import "FeatureMask.h"
#import "Utils.h"
#define MIN_NUMBER_CHARACTERS 0
#define MIN_NUMBER_CHOICES 2
#define BALLOT_CREATE_TABLE_CELL_ID @"BallotCreateTableCellId"
// note: the new ballot object is created on a temorary NSManagedObjectContext, on save it is moved to the main context and saved there
// - the conversation object might get updated while editing the ballot
// - if using the main context a unwanted save may occur while the ballot is in in invalid state
@interface BallotCreateViewController ()
@property NSMutableArray *choices;
@property EntityManager *entityManager;
@property BOOL isNewBallot;
@property Ballot *ballot;
@property Conversation *conversation;
@property BOOL didShowFeatureMaskAlert;
@property (nonatomic, strong) NSIndexPath *indexPathForPicker;
@property (nonatomic, strong) NSDate *lastSelectedDate;
@property (nonatomic) BOOL lastPickerWithoutTime;
@end
@implementation BallotCreateViewController
+ (instancetype) ballotCreateViewControllerForConversation:(Conversation *)conversation {
BallotCreateViewController *viewController = [self ballotCreateViewController];
Conversation *ownContextConversation = (Conversation *)[viewController.entityManager.entityFetcher getManagedObjectById:conversation.objectID];
viewController.conversation = ownContextConversation;
viewController.ballot = [viewController newBallot];
viewController.ballot.conversation = ownContextConversation;
viewController.isNewBallot = YES;
return viewController;
}
+ (instancetype) ballotCreateViewControllerForBallot:(Ballot *)ballot {
BallotCreateViewController *viewController = [self ballotCreateViewController];
viewController.ballot = (Ballot *)[viewController.entityManager.entityFetcher getManagedObjectById:ballot.objectID];
viewController.conversation = viewController.ballot.conversation;
viewController.isNewBallot = NO;
return viewController;
}
+ (instancetype) ballotCreateViewController {
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Ballot" bundle:nil];
BallotCreateViewController *viewController = (BallotCreateViewController *) [storyboard instantiateViewControllerWithIdentifier:@"BallotCreateViewController"];
viewController.entityManager = [[EntityManager alloc] init];
viewController.didShowFeatureMaskAlert = NO;
return viewController;
}
-(void)dealloc {
[self removeFromObserver];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self dismissPicker];
if (![[AppDelegate sharedAppDelegate] active]) {
[_entityManager rollback];
}
}
- (void)viewDidLoad {
[super viewDidLoad];
_cancelButton.target = self;
_cancelButton.action = @selector(cancelPressed);
_sendButton.target = self;
_sendButton.action = @selector(sendPressed);
[_addButton addTarget:self action:@selector(addPressed) forControlEvents:UIControlEventTouchUpInside];
_choiceTableView.delegate = self;
_choiceTableView.dataSource = self;
[self updateUIStrings];
if (_isNewBallot == NO) {
[self setOnlyEditing];
}
[self registerForKeyboardNotifications];
[_titleTextView becomeFirstResponder];
[self setupColors];
}
- (void)setupColors {
self.view.backgroundColor = [Colors background];
_buttonView.backgroundColor = [Colors backgroundDark];
_titleTextView.backgroundColor = [Colors background];
_choiceTableView.backgroundColor = [Colors background];
_hairlineTop.backgroundColor = [Colors fontLight];
_hairlineTop.frame = [RectUtil setHeightOf:_hairlineTop.frame height:0.5];
[_addButton setTintColor:[Colors main]];
[_optionsButton setTitleColor:[Colors main] forState:UIControlStateNormal];
_titleTextView.textColor = [Colors fontNormal];
_headerView.backgroundColor = [Colors background];
[Colors updateKeyboardAppearanceFor:_titleTextView];
}
- (void)setOnlyEditing {
_optionsButton.enabled = NO;
_addButton.enabled = NO;
}
- (void)viewWillAppear:(BOOL)animated {
[self updateContent];
[self checkFeatureMasks];
_indexPathForPicker = nil;
[super viewWillAppear:animated];
}
- (void)updateUIStrings {
if (_isNewBallot) {
[_sendButton setTitle:NSLocalizedString(@"send", nil)];
} else {
[_sendButton setTitle:NSLocalizedString(@"save", nil)];
}
UIFontDescriptor *fontDescriptor = [UIFontDescriptor preferredFontDescriptorWithTextStyle:UIFontTextStyleHeadline];
_titleTextView.attributedPlaceholder =[[NSAttributedString alloc] initWithString:NSLocalizedStringFromTable(@"ballot_placeholder_title", @"Ballot", nil) attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:fontDescriptor.pointSize]}];
[_optionsButton setTitle:NSLocalizedStringFromTable(@"ballot_options", @"Ballot", nil) forState:UIControlStateNormal];
_addButton.accessibilityValue = NSLocalizedStringFromTable(@"ballot_add_choice", @"Ballot", nil);
[self setTitle:NSLocalizedStringFromTable(@"ballot_create", @"Ballot", nil)];
}
- (void)updateContent {
[_titleTextView setText: _ballot.title];
NSArray *sortedChoices = [_ballot choicesSortedByOrder];
if (_choices != nil) {
for (BallotChoice *choice in _choices) {
if ([sortedChoices containsObject: choice] == NO) {
[choice.managedObjectContext deleteObject: choice];
}
}
}
_choices = [NSMutableArray arrayWithArray: sortedChoices];
/* show at least two cells */
for (NSInteger i=[_choices count]; i MIN_NUMBER_CHARACTERS) {
[verifiedChoices addObject:choice];
}
}
return verifiedChoices;
}
- (BOOL)isContentValid {
if ([[self verifiedChoices] count] < MIN_NUMBER_CHOICES) {
NSString *message = NSLocalizedStringFromTable(@"ballot_validation_not_enough_choices", @"Ballot", nil);
[self showAlert: message];
return NO;
} else if (_titleTextView.text <= MIN_NUMBER_CHARACTERS) {
NSString *message = NSLocalizedStringFromTable(@"ballot_validation_title_missing", @"Ballot", nil);
[self showAlert: message];
return NO;
}
return YES;
}
- (void)checkFeatureMasks {
[FeatureMask checkFeatureMask:FEATURE_MASK_BALLOT forContacts:_ballot.participants onCompletion:^(NSArray *unsupportedContacts) {
if ([unsupportedContacts count] > 0) {
[self showFeatureMaskAlertForContacts: unsupportedContacts];
}
}];
}
- (void)showAlert:(NSString *)message {
NSString *title = NSLocalizedStringFromTable(@"ballot_validation_error_title", @"Ballot", nil);
[UIAlertTemplate showAlertWithOwner:self title:title message:message actionOk:nil];
}
- (void)showFeatureMaskAlertForContacts:(NSArray *)contacts {
NSString *messageFormat;
if ([contacts count] == [_ballot.participants count]) {
messageFormat = NSLocalizedStringFromTable(@"ballot_feature_level_error_message", @"Ballot", nil);
} else {
// show warning only once
if (_didShowFeatureMaskAlert) {
return;
}
messageFormat = NSLocalizedStringFromTable(@"ballot_feature_level_warning_message", @"Ballot", nil);
}
NSString *participantNames = [Utils stringFromContacts:contacts];
NSString *message = [NSString stringWithFormat:messageFormat, participantNames];
NSString *title = NSLocalizedStringFromTable(@"ballot_feature_level_warning_title", @"Ballot", nil);
[UIAlertTemplate showAlertWithOwner:self title:title message:message actionOk:nil];
_didShowFeatureMaskAlert = YES;
}
- (Ballot *)newBallot {
Ballot *ballot = [_entityManager.entityCreator ballot];
ballot.id = [[NaClCrypto sharedCrypto] randomBytes:kBallotIdLen];
ballot.createDate = [NSDate date];
ballot.creatorId = [MyIdentityStore sharedMyIdentityStore].identity;
NSUserDefaults *defaults = [AppGroup userDefaults];
NSNumber *type = [defaults objectForKey:@"ballotLastType"];
if (type) {
ballot.type = type;
}
NSNumber *assessmentType = [defaults objectForKey:@"ballotLastAssessmentType"];
if (assessmentType) {
ballot.assessmentType = assessmentType;
}
return ballot;
}
- (void)addChoiceToTable {
[_choiceTableView beginUpdates];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:[_choices count] inSection:0];
[self addChoice];
[_choiceTableView insertRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationTop];
[_choiceTableView endUpdates];
}
- (void)updateAvailableCells {
NSSet *verifiedChoices = [self verifiedChoices];
if ([verifiedChoices count] >= [_choices count]) {
[self addChoiceToTable];
}
}
- (void)dismissPicker {
if (_indexPathForPicker) {
BallotCreateTableCell *selectedCell = [_choiceTableView cellForRowAtIndexPath:_indexPathForPicker];
[selectedCell showDatePicker:nil];
_indexPathForPicker = nil;
}
}
#pragma mark - button actions
- (void)addPressed {
[self addChoiceToTable];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[self setFirstResponderAfterIndexPath: indexPath];
}
- (void)sendPressed {
if ([self isContentValid] == NO) {
return;
}
[self updateEntityObjects];
NSUserDefaults *defaults = [AppGroup userDefaults];
[defaults setObject:_ballot.type forKey:@"ballotLastType"];
[defaults setObject:_ballot.assessmentType forKey:@"ballotLastAssessmentType"];
[_entityManager performSyncBlockAndSafe:nil];
[MessageSender sendCreateMessageForBallot:_ballot];
[self.navigationController dismissViewControllerAnimated:YES completion:nil];
}
- (void)cancelPressed {
[_entityManager rollback];
[self.navigationController dismissViewControllerAnimated:YES completion:nil];
}
#pragma mark - table cell callback
- (void)didUpdateCell:(BallotCreateTableCell *)cell {
NSIndexPath *indexPath = [_choiceTableView indexPathForCell: cell];
if ([cell.choiceTextField isFirstResponder]) {
[self updateAvailableCells];
[self setFirstResponderAfterIndexPath: indexPath];
}
}
- (void)showPickerForCell:(BallotCreateTableCell *)cell {
if (_indexPathForPicker) {
BallotCreateTableCell *lastCell = [_choiceTableView cellForRowAtIndexPath:_indexPathForPicker];
[lastCell showDatePicker:nil];
}
[_choiceTableView endEditing:YES];
[_choiceTableView resignFirstResponder];
[_titleTextView resignFirstResponder];
if (_lastSelectedDate) {
[cell setInputText:_lastSelectedDate allDay:_lastPickerWithoutTime];
}
[_choiceTableView beginUpdates];
_indexPathForPicker = [_choiceTableView indexPathForCell:cell];
[_choiceTableView endUpdates];
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
}
- (void)hidePickerForCell:(BallotCreateTableCell *)cell {
_lastSelectedDate = cell.datePicker.date;
_lastPickerWithoutTime = cell.allDaySwitch.on;
[_choiceTableView beginUpdates];
_indexPathForPicker = nil;
[_choiceTableView endUpdates];
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil);
}
- (void)setFirstResponderAfterIndexPath:(NSIndexPath *)indexPath {
NSInteger index = indexPath.row;
while (index < [_choices count]) {
NSIndexPath *newIndexPath = [NSIndexPath indexPathForRow:index inSection:0];
BallotCreateTableCell *nextCell = (BallotCreateTableCell *)[_choiceTableView cellForRowAtIndexPath: newIndexPath];
if ([nextCell.choiceTextField.text length] <= 0) {
[nextCell.choiceTextField becomeFirstResponder];
break;
}
index++;
}
}
#pragma mark - table view data source / delegate
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
[Colors updateTableViewCell:cell];
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
if (_indexPathForPicker && indexPath.section == _indexPathForPicker.section && indexPath.row == _indexPathForPicker.row) {
if (@available(iOS 14.0, *)) {
return 450.0;
} else {
return 300.0;
}
}
return UITableViewAutomaticDimension;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return [_choices count];
}
- (UITableViewCell *)tableView: (UITableView *) tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
BallotCreateTableCell *cell = [tableView dequeueReusableCellWithIdentifier: BALLOT_CREATE_TABLE_CELL_ID];
if (cell == nil) {
//Fallback
cell = [[BallotCreateTableCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier: BALLOT_CREATE_TABLE_CELL_ID];
}
BallotChoice *choice = [_choices objectAtIndex: indexPath.row];
cell.choice = choice;
cell.delegate = self;
if (_isNewBallot == NO) {
cell.userInteractionEnabled = NO;
}
return cell;
}
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath {
if (editingStyle == UITableViewCellEditingStyleDelete) {
BallotChoice *choice = [_choices objectAtIndex:indexPath.row];
[[_entityManager entityDestroyer] deleteObjectWithObject:choice];
[_choices removeObjectAtIndex: indexPath.row];
[tableView deleteRowsAtIndexPaths:[NSArray arrayWithObject:indexPath] withRowAnimation:UITableViewRowAnimationNone];
}
}
- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath
{
if (_isNewBallot) {
return UITableViewCellEditingStyleDelete;
} else {
return UITableViewCellEditingStyleNone;
}
}
- (BOOL)tableView:(UITableView *)tableView canMoveRowAtIndexPath:(NSIndexPath *)indexPath {
[self dismissPicker];
return YES;
}
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath {
BallotChoice *choiceToMove = [_choices objectAtIndex:sourceIndexPath.row];
[_choices removeObjectAtIndex:sourceIndexPath.row];
[_choices insertObject:choiceToMove atIndex:destinationIndexPath.row];
}
# pragma mark Keyboard Notifications
- (void)registerForKeyboardNotifications {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillShow:)
name:UIKeyboardWillShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardDidShow:)
name:UIKeyboardDidShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillHide:)
name:UIKeyboardWillHideNotification object:nil];
}
- (void)removeFromObserver {
[[NSNotificationCenter defaultCenter] removeObserver: self];
}
- (void)keyboardWillShow: (NSNotification*) aNotification {
NSDictionary* info = [aNotification userInfo];
CGRect keyboardRect = [[info objectForKey: UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGRect keyboardRectConverted = [_choiceTableView convertRect: keyboardRect fromView: nil];
CGSize keyboardSize = keyboardRectConverted.size;
UIEdgeInsets contentInsets = UIEdgeInsetsMake(0.0, 0.0, keyboardSize.height, 0.0);
_choiceTableView.contentInset = contentInsets;
_choiceTableView.scrollIndicatorInsets = contentInsets;
}
- (void)keyboardDidShow:(NSNotification *)aNotification {
if (_indexPathForPicker) {
BallotCreateTableCell *lastCell = [_choiceTableView cellForRowAtIndexPath:_indexPathForPicker];
if (@available(iOS 14.0, *)) {
if (!lastCell.choiceTextField.isFirstResponder) {
[_choiceTableView scrollToRowAtIndexPath:_indexPathForPicker atScrollPosition:UITableViewScrollPositionBottom animated:true];
return;
}
}
[lastCell showDatePicker:nil];
}
}
- (void)keyboardWillHide:(NSNotification*)aNotification
{
_choiceTableView.contentInset = UIEdgeInsetsZero;
_choiceTableView.scrollIndicatorInsets = UIEdgeInsetsZero;
}
#pragma mark - Navigation
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
if ([segue.identifier isEqualToString:@"ballotOptionsSegue"]) {
[self updateEntityObjects];
BallotCreateDetailViewController *controller = (BallotCreateDetailViewController*)segue.destinationViewController;
controller.ballot = _ballot;
}
}
@end