// _____ _
// |_ _| |_ _ _ ___ ___ _ __ __ _
// | | | ' \| '_/ -_) -_) ' \/ _` |_
// |_| |_||_|_| \___\___|_|_|_\__,_(_)
//
// 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 "BallotResultMatrixView.h"
#import "Conversation.h"
#import "Contact.h"
#import "BallotChoice.h"
#import "BallotResult.h"
#import "RectUtil.h"
#import "MyIdentityStore.h"
#import "BallotMatrixLabelView.h"
#import "BallotResultMatrixCell.h"
#import "SlaveScrollView.h"
#import "ScrollViewContent.h"
#import "PopoverView.h"
#import "AvatarMaker.h"
#import "BundleUtil.h"
#define DEGREES_TO_RADIANS(x) (M_PI * (x) / 180.0)
#define LABEL_RADIANS -0.9
#define X_PADDING 8.0f
#define TOP_PADDING 0.0f
#define BOTTOM_PADDING 8.0f
#define MATRIX_PADDING 0.0f
#define BORDER_WIDTH 1.0
#define BORDER_COLOR [Colors background]
#define GRID_HEIGHT 36.0f
#define GRID_WIDTH 34.0f
#define TOTALS_WIDTH 36.0f
#define LABEL_LENGTH_CONTACT 100.0f
#define CONTACT_Y_OFFSET_CORRECTION -10.0f
#define CONTACT_FONT_SIZE 14.0f
#define CHOICE_FONT_SIZE 14.0f
#define CONTACT_AVATAR_PADDING 2.0f
#define CONTACT_AVATAR_SIZE GRID_WIDTH
#define HIGHEST_VOTE_COLOR [Colors ballotHighestVote]
#define ROW_COLOR_LIGHT [Colors ballotRowLight]
#define ROW_COLOR_DARKER [Colors ballotRowDark]
@interface BallotResultMatrixView ()
@property NSInteger numChoices;
@property NSInteger numParticipants;
@property NSMutableArray *participantIds;
@property NSMutableArray *participantNames;
@property NSMutableArray *participantAvatars;
@property (nonatomic) CGRect matrixRect;
@property CGFloat gridHeight;
@property CGFloat gridWidth;
@property CGFloat totalsWidth;
@property CGFloat labelAngleRadians;
@property CGFloat minChoiceLabelLength;
@property CGFloat contactLabelLength;
@property CGFloat contactLabelHeight;
@property SlaveScrollView *choicesView;
@property SlaveScrollView *contactsView;
@property SlaveScrollView *matrixView;
@property SlaveScrollView *totalsView;
@property UIView *totalsHeaderView;
@property CGPoint beginTouchPoint;
@property CGPoint endTouchPoint;
@property NSMutableArray *highestVotes;
@property PopoverView *popoverView;
@property UIPanGestureRecognizer *panGesture;
@end
@implementation BallotResultMatrixView
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
_labelAngleRadians = LABEL_RADIANS;
_gridWidth = GRID_WIDTH;
_gridHeight = GRID_HEIGHT;
CGFloat minSideLength = fminf(CGRectGetWidth(self.frame), CGRectGetHeight(self.frame));
_minChoiceLabelLength = minSideLength/3.0;
_contactLabelHeight = GRID_WIDTH;
_contactLabelLength = LABEL_LENGTH_CONTACT;
_totalsWidth = TOTALS_WIDTH;
_endTouchPoint = CGPointMake(0.0, 0.0);
_highestVotes = [NSMutableArray array];
self.userInteractionEnabled = YES;
_panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
[self addGestureRecognizer:_panGesture];
self.backgroundColor = [Colors background];
}
return self;
}
- (void)adaptLayoutToSize:(CGSize)size {
[_popoverView dismiss];
_popoverView = nil;
_matrixRect = [self matrixRectForSize:size];
_choicesView.frame = [self choicesRectForSize:size];
CGRect newContactsRect = [self contactsRectForSize:size];
if (CGSizeEqualToSize(newContactsRect.size, _contactsView.frame.size) == NO) {
ScrollViewContent *matrixContent = [self makeMatrixView];
[_matrixView setContent: matrixContent];
ScrollViewContent *contactsContent = [self makeContactsViewForSize:size];
[_contactsView setContent: contactsContent];
[self updateLineColors];
[self markHighestVotes];
[self setNeedsLayout];
}
_contactsView.frame = newContactsRect;
_matrixView.frame = _matrixRect;
_totalsView.frame = [self totalsRectForSize:size];
}
- (void)adaptToInterfaceRotation {
[self adaptLayoutToSize:self.frame.size];
}
- (void)adaptToSize:(CGSize)size {
[self adaptLayoutToSize:size];
}
- (void)setBallot:(Ballot *)ballot {
_ballot = ballot;
[self updateParticipants];
_numChoices = [_ballot.choicesSortedByOrder count];
_numParticipants = [_participantIds count];
[self drawDataForSize:self.frame.size];
}
- (CGRect)matrixRectForSize:(CGSize)size {
CGFloat offsetLeft = X_PADDING + MATRIX_PADDING + [self choicesWidthForSize:size] + _totalsWidth;
CGFloat offsetRight = X_PADDING;
CGFloat width = size.width - offsetLeft - offsetRight;
CGFloat contactsHeight = [self contactsHeightForSize:size];
CGFloat height = size.height - TOP_PADDING - BOTTOM_PADDING - contactsHeight;
return CGRectMake(offsetLeft, TOP_PADDING + contactsHeight, width, height);
}
- (CGRect)choicesRectForSize:(CGSize)size {
CGFloat contactsHeight = [self contactsHeightForSize:size];
CGFloat y = TOP_PADDING + contactsHeight;
CGFloat height = size.height - TOP_PADDING - BOTTOM_PADDING - contactsHeight;
return CGRectMake(X_PADDING, y, [self choicesWidthForSize:size], height);
}
- (CGRect)contactsRectForSize:(CGSize)size {
return CGRectMake(_matrixRect.origin.x, TOP_PADDING, _matrixRect.size.width, [self contactsHeightForSize:size]);
}
- (CGRect)totalsRectForSize:(CGSize)size {
return CGRectMake(X_PADDING + [self choicesWidthForSize:size], TOP_PADDING + [self contactsHeightForSize:size], _totalsWidth, _matrixRect.size.height);
}
- (CGFloat)sin {
CGFloat absRad = ABS(_labelAngleRadians);
return sinf(absRad);
}
- (CGFloat)cos {
CGFloat absRad = ABS(_labelAngleRadians);
return cosf(absRad);
}
- (CGFloat)choicesWidthForSize:(CGSize)size {
CGFloat width = size.width - 2*X_PADDING - _totalsWidth - MATRIX_PADDING - [self contactsTotalWidth];
// minimum choice length
return fmaxf(width, _minChoiceLabelLength);
}
- (CGFloat)contactsHeightForSize:(CGSize)size {
if (SYSTEM_IS_IPAD || size.height > size.width) {
return _contactLabelLength * [self sin] + _contactLabelHeight * [self cos] + CONTACT_AVATAR_SIZE;
} else {
return CONTACT_AVATAR_SIZE;
}
}
- (CGFloat)contactsWidth {
if (SYSTEM_IS_IPAD || UIInterfaceOrientationIsPortrait([UIApplication sharedApplication].statusBarOrientation)) {
return _contactLabelLength * [self cos] + _contactLabelHeight * [self sin];
} else {
return CONTACT_AVATAR_SIZE;
}
}
- (CGFloat)contactsTotalWidth {
return (_numParticipants - 1) * _gridWidth + [self contactsWidth];
}
- (void)drawDataForSize:(CGSize)size {
_matrixRect = [self matrixRectForSize:size];
ScrollViewContent *choicesContent = [self makeChoicesViewForSize:size];
_choicesView = [[SlaveScrollView alloc] initWithFrame: [self choicesRectForSize:size]];
_choicesView.horizontalScrollingEnabled = NO;
[_choicesView.panGestureRecognizer requireGestureRecognizerToFail: _panGesture];
[_choicesView setContent: choicesContent];
[self addSubview:_choicesView];
ScrollViewContent *contactsContent = [self makeContactsViewForSize:size];
_contactsView = [[SlaveScrollView alloc] initWithFrame: [self contactsRectForSize:size]];
[_contactsView.panGestureRecognizer requireGestureRecognizerToFail: _panGesture];
[_contactsView setContent: contactsContent];
[self addSubview:_contactsView];
ScrollViewContent *matrixContent = [self makeMatrixView];
_matrixView = [[SlaveScrollView alloc] initWithFrame: _matrixRect];
[_matrixView.panGestureRecognizer requireGestureRecognizerToFail: _panGesture];
[_matrixView setContent: matrixContent];
[self addSubview:_matrixView];
CGRect totalsRect = [self totalsRectForSize:size];
ScrollViewContent *totalsContent = [self makeResultTotalsView];
_totalsView = [[SlaveScrollView alloc] initWithFrame: totalsRect];
[_totalsView.panGestureRecognizer requireGestureRecognizerToFail: _panGesture];
[_totalsView setContent: totalsContent];
[self addSubview:_totalsView];
[self updateLineColors];
[self markHighestVotes];
[self setNeedsLayout];
}
- (void)updateLineColors {
UIColor *color;
for (NSInteger i = 0; i < _numChoices; i++) {
if (i % 2 == 0) {
color = ROW_COLOR_LIGHT;
} else {
color = ROW_COLOR_DARKER;
}
[_choicesView setColor:color forRowAt:i];
[_totalsView setColor:color forRowAt:i];
[_matrixView setColor:color forRowAt:i];
}
}
- (void)markHighestVotes {
for (NSNumber *indexNumber in _highestVotes) {
NSInteger idx = indexNumber.integerValue;
[_choicesView setColor:HIGHEST_VOTE_COLOR forRowAt:idx];
[_totalsView setColor:HIGHEST_VOTE_COLOR forRowAt:idx];
[_matrixView setColor:HIGHEST_VOTE_COLOR forRowAt:idx];
if ([Colors getTheme] == ColorThemeLight || [Colors getTheme] == ColorThemeLightWork) {
[_choicesView setTextColor:[Colors fontInverted] forRowAt:idx];
[_totalsView setTextColor:[Colors fontInverted] forRowAt:idx];
[_matrixView setTextColor:[Colors fontInverted] forRowAt:idx];
}
}
}
- (ScrollViewContent *)makeContactsViewForSize:(CGSize)size {
CGFloat height = [self contactsHeightForSize:size];
BOOL showLabel = height > CONTACT_AVATAR_SIZE;
CGRect contactRect;
CGFloat yOffsetAvatar;
CGFloat totalWidth;
if (showLabel) {
contactRect = [self contactsLabelRectForSize:size];
totalWidth = [self contactsTotalWidth];
yOffsetAvatar = height - CONTACT_AVATAR_SIZE;
} else {
contactRect = CGRectMake(0.0, 0.0, CONTACT_AVATAR_SIZE, CONTACT_AVATAR_SIZE);
totalWidth = _participantNames.count * CONTACT_AVATAR_SIZE;
yOffsetAvatar = 0.0;
}
CGRect totalRect = CGRectMake(0.0, 0.0, totalWidth, height);
ScrollViewContent *contactView = [[ScrollViewContent alloc] initWithFrame:totalRect];
for (int i = 0; i < _participantNames.count; i++) {
if (showLabel) {
NSString *participant = _participantNames[i];
contactRect = [RectUtil offsetRect:contactRect byX:_gridWidth byY:0.0];
CGRect labelRect = [RectUtil offsetRect:contactRect byX:0 byY:-CONTACT_AVATAR_SIZE];
BallotMatrixLabelView *contactLabel = [self rotatedLabelAt:labelRect withText:participant];
contactLabel.font = [UIFont systemFontOfSize: CONTACT_FONT_SIZE];
[contactView addSubview: contactLabel];
}
UIImage *avatar = _participantAvatars[i];
UIImageView *contactAvatar = [[UIImageView alloc] initWithImage:avatar];
CGRect avatarRect = CGRectMake(i*GRID_WIDTH, yOffsetAvatar, CONTACT_AVATAR_SIZE, CONTACT_AVATAR_SIZE);
avatarRect = CGRectInset(avatarRect, CONTACT_AVATAR_PADDING, CONTACT_AVATAR_PADDING);
contactAvatar.frame = avatarRect;
contactAvatar.tag = i;
[contactView addSubview: contactAvatar];
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
[contactAvatar addGestureRecognizer:tapGesture];
contactAvatar.userInteractionEnabled = YES;
}
return contactView;
}
- (CGRect)contactsLabelRectForSize:(CGSize)size {
CGFloat height = [self contactsHeightForSize:size];
CGFloat width = [self contactsWidth];
CGFloat rotationXOffset = (width - _contactLabelLength)/2.0 - _contactLabelHeight * [self sin] + CONTACT_Y_OFFSET_CORRECTION;
CGFloat rotationYOffset = (height - _contactLabelHeight + CONTACT_AVATAR_SIZE)/2.0;
return CGRectMake(rotationXOffset, rotationYOffset, _contactLabelLength, _contactLabelHeight);
}
- (BallotMatrixLabelView *)rotatedLabelAt:(CGRect)rect withText:(NSString*)string {
CGFloat sin = [self sin];
CGFloat yLabelOffset = rect.size.height - rect.size.height * sin;
BallotMatrixLabelView *label = [BallotMatrixLabelView labelForString:string at:rect];
[label offsetAndResizeHeight: yLabelOffset];
label.transform = CGAffineTransformMakeRotation(_labelAngleRadians);
return label;
}
- (ScrollViewContent *)makeChoicesViewForSize:(CGSize)size {
CGFloat totalHeight = _numChoices * _gridHeight;
CGRect totalRect = CGRectMake(0.0, 0.0, _minChoiceLabelLength, totalHeight);
ScrollViewContent *choiceView = [[ScrollViewContent alloc] initWithFrame:totalRect];
// create views & get max width
CGFloat yOffset = 0.0;
CGFloat maxWidth = 0.0;
NSMutableArray *labelViews = [NSMutableArray array];
for (BallotChoice *choice in _ballot.choicesSortedByOrder) {
CGRect contactRect = CGRectMake(0.0, yOffset, _minChoiceLabelLength, _gridHeight);
BallotMatrixLabelView *choiceLabel = [BallotMatrixLabelView labelForString:choice.name at:contactRect];
choiceLabel.accessibilityValue = [self accessibilityValueForChoice:choice];
choiceLabel.isAccessibilityElement = YES;
choiceLabel.font = [UIFont systemFontOfSize: CHOICE_FONT_SIZE];
choiceLabel.borderWidth = BORDER_WIDTH;
choiceLabel.borderColor = BORDER_COLOR;
choiceLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[choiceLabel sizeToFit];
[labelViews addObject: choiceLabel];
yOffset += _gridHeight;
maxWidth = fmaxf(maxWidth, CGRectGetWidth(choiceLabel.frame));
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
[choiceLabel addGestureRecognizer:tapGesture];
}
if (maxWidth < [self choicesWidthForSize:size]) {
maxWidth = [self choicesWidthForSize:size];
choiceView.frame = [RectUtil setWidthOf:choiceView.frame width:maxWidth];
}
choiceView.minWidth = maxWidth;
// resize and insert
for (BallotMatrixLabelView *label in labelViews) {
label.frame = [RectUtil setWidthOf:label.frame width:maxWidth];
[choiceView addSubview: label];
}
return choiceView;
}
- (NSString *)accessibilityValueForChoice:(BallotChoice *)choice {
NSMutableString *participants = [NSMutableString string];
for (NSString *identity in choice.participantIdsForResultsTrue) {
NSInteger index = [_participantIds indexOfObject:identity];
if (index != NSNotFound) {
if (participants.length > 0) {
[participants appendString:@", "];
}
[participants appendString:_participantNames[index]];
}
}
NSString *votesCountFormat = NSLocalizedStringFromTable(@"ballot_votes_count", @"Ballot", nil);
NSString *votesCount = [NSString stringWithFormat:votesCountFormat, [NSString stringWithFormat: @"%li", (long)[choice totalCountOfResultsTrue]]];
// use commas to create a pause for voice over
return [NSString stringWithFormat:@"%@, %@, %@", choice.name, votesCount, participants];
}
- (ScrollViewContent *)makeResultTotalsView {
CGFloat height = _numChoices * _gridHeight;
CGRect rect = CGRectMake(0.0, 0.0, _totalsWidth, height);
ScrollViewContent *resultTotalsView = [[ScrollViewContent alloc] initWithFrame:rect];
resultTotalsView.layer.borderWidth = 0.5;
resultTotalsView.layer.borderColor = BORDER_COLOR.CGColor;
CGFloat xOffset = 0.0;
CGFloat yOffset = 0.0;
NSInteger index = 0;
NSInteger maxCount = 0;
for (BallotChoice *choice in _ballot.choicesSortedByOrder) {
NSInteger count = [choice totalCountOfResultsTrue];
if (count > 0) {
if (count == maxCount) {
[_highestVotes addObject: [NSNumber numberWithInteger:index]];
} else if (count > maxCount) {
maxCount = count;
[_highestVotes removeAllObjects];
[_highestVotes addObject:[NSNumber numberWithInteger:index]];
}
}
CGRect rect = CGRectMake(xOffset, yOffset, _totalsWidth, _gridHeight);
rect = [RectUtil rect:rect centerHorizontalIn:resultTotalsView.bounds];
NSString *text = [NSString stringWithFormat:@"%li", (long)count];
BallotMatrixLabelView *label = [BallotMatrixLabelView labelForString:text at:rect];
label.maxWidth = _totalsWidth;
label.textAlignment = NSTextAlignmentRight;
label.borderWidth = BORDER_WIDTH;
label.borderColor = BORDER_COLOR;
[resultTotalsView addSubview:label];
yOffset += _gridHeight;
index++;
}
return resultTotalsView;
}
- (ScrollViewContent *)makeMatrixView {
CGFloat totalHeight = _numChoices * _gridHeight;
CGFloat rowWidth = _numParticipants * _gridWidth;
CGFloat totalWidth = [self contactsTotalWidth];
CGRect rect = CGRectMake(0.0, 0.0, totalWidth, totalHeight);
ScrollViewContent *matrixView = [[ScrollViewContent alloc] initWithFrame:rect];
CGFloat xOffset = 0.0;
CGFloat yOffset = 0.0;
for (BallotChoice *choice in _ballot.choicesSortedByOrder) {
CGRect rowRect = CGRectMake(0.0, yOffset, rowWidth, _gridHeight);
UIView *rowView = [[UIView alloc] initWithFrame: rowRect];
for (NSString *participantId in _participantIds) {
CGRect rect = CGRectMake(xOffset, 0.0, _gridWidth, _gridHeight);
BallotResultMatrixCell *result = [[BallotResultMatrixCell alloc] initWithFrame:rect];
[result updateResultForChoice:choice andParticipant:participantId];
result.borderWidth = BORDER_WIDTH;
result.borderColor = BORDER_COLOR;
[rowView addSubview:result];
xOffset += _gridWidth;
}
[matrixView addSubview:rowView];
yOffset += _gridHeight;
xOffset = 0.0;
}
return matrixView;
}
- (void)updateParticipants {
_participantIds = [NSMutableArray array];
_participantNames = [NSMutableArray array];
_participantAvatars = [NSMutableArray array];
NSString *myIdentity = [MyIdentityStore sharedMyIdentityStore].identity;
[_participantIds addObject:myIdentity];
[_participantNames addObject:[BundleUtil localizedStringForKey:@"me"]];
NSMutableDictionary *profilePicture = [[MyIdentityStore sharedMyIdentityStore] profilePicture];
UIImage *image = [UIImage imageWithData:profilePicture[@"ProfilePicture"]];
if (image) {
[_participantAvatars addObject:[[AvatarMaker sharedAvatarMaker] maskedProfilePicture:image size:CONTACT_AVATAR_SIZE-2*CONTACT_AVATAR_PADDING]];
} else {
[_participantAvatars addObject:[[AvatarMaker sharedAvatarMaker] avatarForContact:nil size:CONTACT_AVATAR_SIZE-2*CONTACT_AVATAR_PADDING masked:YES]];
}
for (Contact *contact in _ballot.participants) {
[_participantIds addObject:contact.identity];
[_participantNames addObject:contact.displayName];
[_participantAvatars addObject:[[AvatarMaker sharedAvatarMaker] avatarForContact:contact size:CONTACT_AVATAR_SIZE-2*CONTACT_AVATAR_PADDING masked:YES]];
}
}
#pragma mark - touch handling
- (void)pan:(UIPanGestureRecognizer *)gestureRecognizer {
CGPoint position = [gestureRecognizer locationInView:self];
switch (gestureRecognizer.state) {
case UIGestureRecognizerStateBegan:
_beginTouchPoint = position;
break;
case UIGestureRecognizerStateChanged:
[self panChangedTo:position];
break;
case UIGestureRecognizerStateEnded:
_endTouchPoint = _matrixView.position;
break;
default:
break;
}
}
- (void)panChangedTo:(CGPoint)position
{
CGPoint diffWithOffset = [self positionDiffFor:position];
[self updateSlaveViewsToPosition:diffWithOffset];
}
- (void)updateSlaveViewsToPosition:(CGPoint)position {
[_choicesView setPosition:position];
[_totalsView setPosition:position];
CGPoint matrixPos = [_matrixView setPosition:position];
[_contactsView setPosition:matrixPos];
}
- (CGPoint)positionDiffFor:(CGPoint)position {
CGPoint positionDiff = [self diffFromPoint:_beginTouchPoint toPoint:position];
CGPoint diffWithOffset = [self addPoint:_endTouchPoint toPoint:positionDiff];
return diffWithOffset;
}
- (CGPoint)diffFromPoint:(CGPoint)fromPoint toPoint:(CGPoint)toPoint {
CGFloat x = fromPoint.x - toPoint.x;
CGFloat y = fromPoint.y - toPoint.y;
return CGPointMake(x, y);
}
- (CGPoint)addPoint:(CGPoint)point toPoint:(CGPoint)toPoint {
CGFloat x = toPoint.x + point.x;
CGFloat y = toPoint.y + point.y;
return CGPointMake(x, y);
}
#pragma mark - UITapGestureRecognizer
- (void)handleTap:(UITapGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateEnded)
{
if ([sender.view isKindOfClass:[BallotMatrixLabelView class]]) {
BallotMatrixLabelView *label = (BallotMatrixLabelView *)sender.view;
CGPoint point = [self convertPoint:label.bounds.origin fromView:label];
_popoverView = [PopoverView showPopoverAtPoint:point inView:self withTitle:nil withText:label.text delegate:self];
} else if ([sender.view isKindOfClass:[UIImageView class]]) {
UIImageView *avatarView = (UIImageView *)sender.view;
NSInteger index = [self indexForAvatarImage:avatarView.image];
if (index >= 0) {
CGPoint point = [self convertPoint:avatarView.bounds.origin fromView:avatarView];
point.x += avatarView.bounds.size.width/2.0;
NSString *name = [_participantNames objectAtIndex:avatarView.tag];
_popoverView = [PopoverView showPopoverAtPoint:point inView:self withTitle:nil withText:name delegate:self];
}
}
}
}
- (NSInteger)indexForAvatarImage:(UIImage *)image {
for (NSInteger i=0; i<[_participantAvatars count]; i++) {
UIImage *avatar = [_participantAvatars objectAtIndex:i];
if (image == avatar) {
return i;
}
}
return -1;
}
#pragma mark - Popover delegate
- (void)popoverViewDidDismiss:(PopoverView *)popoverView {
_popoverView = nil;
}
@end