// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/unit_conversion/ui_bundled/unit_conversion_view_controller.h"
#import <vector>
#import "base/check.h"
#import "base/debug/dump_without_crashing.h"
#import "base/notreached.h"
#import "base/strings/sys_string_conversions.h"
#import "components/crash/core/common/crash_key.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/unit_conversion/ui_bundled/unit_conversion_constants.h"
#import "ios/chrome/browser/unit_conversion/ui_bundled/unit_conversion_mutator.h"
#import "ios/chrome/browser/unit_conversion/ui_bundled/unit_conversion_view_controller_delegate.h"
#import "ios/chrome/browser/unit_conversion/ui_bundled/unit_type_cell.h"
#import "ios/chrome/browser/unit_conversion/ui_bundled/unit_type_value_field_cell.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/public/provider/chrome/browser/unit_conversion/unit_conversion_api.h"
#import "ui/base/l10n/l10n_util.h"
namespace {
// The size of the `Report an issue` button icon.
const CGFloat kReportAnIssueIconSize = 15;
// The trailing and leading offsets of the `Report an issue` button.
const CGFloat kReportAnIssueButtonTrailingOffset = 8;
const CGFloat kReportAnIssueButtonLeadingOffset = 8;
// Table's top inset and header/footer heights of its sections.
const CGFloat kSectionHeaderHeight = 8;
const CGFloat kSectionFooterHeight = 8;
// Size constraint of the nav bar close button.
const CGFloat kCloseButtonIconSize = 30;
// Size constraint of the title button.
const CGFloat kTitleIconSize = 15;
// The padding between the icon and the text of the `Report an issue` button.
const CGFloat kReportAnIssueButtonPadding = 4;
// The padding between the icon and the text of the unit title button.
const CGFloat kUnitTitlePadding = 4;
// Cells identifiers.
NSString* kUnitTypeCellIdentifier = @"UnitTypeCell";
NSString* kUnitTypeFieldCellIdentifier = @"UnitTypeValueFieldCell";
// Source and target sections indexes.
const NSInteger kSourceSection = 0;
const NSInteger kTargetSection = 1;
// Unit type and unit value field rows indexes.
const NSInteger kUnitTypeRow = 0;
const NSInteger kUnitValueFieldRow = 1;
// The height offset to add to the computed preferredContentSize's height.
const CGFloat kTableViewHeightOffset = 16;
// Returns the `UnitType` group for the given `unit`.
ios::provider::UnitType TypeByUnit(NSUnit* unit) {
if ([unit isKindOfClass:[NSUnitArea class]]) {
return ios::provider::kUnitTypeArea;
}
if ([unit isKindOfClass:[NSUnitInformationStorage class]]) {
return ios::provider::kUnitTypeInformationStorage;
}
if ([unit isKindOfClass:[NSUnitLength class]]) {
return ios::provider::kUnitTypeLength;
}
if ([unit isKindOfClass:[NSUnitMass class]]) {
return ios::provider::kUnitTypeMass;
}
if ([unit isKindOfClass:[NSUnitSpeed class]]) {
return ios::provider::kUnitTypeSpeed;
}
if ([unit isKindOfClass:[NSUnitTemperature class]]) {
return ios::provider::kUnitTypeTemperature;
}
if ([unit isKindOfClass:[NSUnitVolume class]]) {
return ios::provider::kUnitTypeVolume;
}
return ios::provider::kUnitTypeUnknown;
}
} // namespace
@interface UnitConversionViewController () <UITableViewDelegate,
UITableViewDataSource,
UITextFieldDelegate> {
// The title button to change the unit type.
UIButton* _unitTypeTitleButton;
// The names of the source/target unit as they should be displayed.
NSString* _formattedSourceUnit;
NSString* _formattedTargetUnit;
// The string to be displayed as UnitTypeValueFieldCell's text for source and
// target values.
NSString* _sourceUnitValueField;
NSString* _targetUnitValueField;
// The view to assign to the tableView's footer, where the `Report an issue`
// button is added.
UIView* _tableViewFooterView;
// The `Report an issue` button.
UIButton* _reportAnIssueButton;
// A copy of `self.view.bounds.size.height` before its change, made during
// calls to viewDidLayoutSubviews, and used to reduce the number of calls to
// calculatePreferredContentHeight.
CGFloat _previousHeight;
}
@property(nonatomic, strong) NSUnit* targetUnit;
@property(nonatomic, strong) NSUnit* sourceUnit;
// The text to be displayed as the title of the presenting view controller.
@property(nonatomic, copy) NSString* unitTypeTitle;
// The unit value.
@property(nonatomic, assign) double unitValue;
// The list of supported the unit types.
@property(nonatomic, assign) std::vector<ios::provider::UnitType> unitTypes;
// The unit type used to compute the title.
@property(nonatomic, assign) ios::provider::UnitType unitType;
@end
@implementation UnitConversionViewController
- (instancetype)initWithSourceUnit:(NSUnit*)sourceUnit
targetUnit:(NSUnit*)targetUnit
unitValue:(double)unitValue {
self = [super initWithStyle:UITableViewStyleInsetGrouped];
if (self) {
_unitValue = unitValue;
_sourceUnit = sourceUnit;
_targetUnit = targetUnit;
_previousHeight = 0;
}
return self;
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
// Capture the tableView's width and use it to calculate the footer's size.
CGFloat width = self.tableView.bounds.size.width;
CGSize size = [_reportAnIssueButton
systemLayoutSizeFittingSize:CGSizeMake(
width,
UILayoutFittingCompressedSize.height)];
// Check the height change to avoid triggering a new layout cycle every time
// the height is set.
if (!AreCGFloatsEqual(_reportAnIssueButton.frame.size.height, size.height)) {
CGRect frame = _reportAnIssueButton.frame;
frame.size.height = size.height;
_tableViewFooterView.frame = frame;
self.tableView.tableFooterView = _tableViewFooterView;
}
// Check for height change before computing the new height.
if (!AreCGFloatsEqual(_previousHeight, self.view.bounds.size.height)) {
self.preferredContentSize =
CGSizeMake(self.preferredContentSize.width,
[self calculatePreferredContentHeight]);
_previousHeight = self.view.bounds.size.height;
}
}
- (void)viewDidLoad {
[super viewDidLoad];
_unitType = TypeByUnit(_sourceUnit);
_sourceUnitValueField =
[NSString localizedStringWithFormat:@"%g", _unitValue];
_unitTypes = ios::provider::GetSupportedUnitTypes();
_unitTypeTitle = [self titleForUnitType:_unitType];
_formattedSourceUnit = ios::provider::GetFormattedUnit(_sourceUnit);
_formattedTargetUnit = ios::provider::GetFormattedUnit(_targetUnit);
[self computeTargetUnitValueField];
UIButton* closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
UIImage* closeIcon =
DefaultSymbolWithPointSize(kXMarkCircleFillSymbol, kCloseButtonIconSize);
[closeButton
setImage:SymbolWithPalette(closeIcon,
@[
[UIColor colorNamed:kTextSecondaryColor],
[UIColor colorNamed:kGrey200Color]
])
forState:UIControlStateNormal];
[closeButton addTarget:self
action:@selector(closeButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
self.navigationItem.rightBarButtonItem =
[[UIBarButtonItem alloc] initWithCustomView:closeButton];
_unitTypeTitleButton = [self createUnitTypeTitleButton];
self.navigationItem.titleView = _unitTypeTitleButton;
_tableViewFooterView = [[UIView alloc] init];
_reportAnIssueButton = [self createReportIssueButton];
_reportAnIssueButton.translatesAutoresizingMaskIntoConstraints = NO;
[_tableViewFooterView addSubview:_reportAnIssueButton];
self.tableView.sectionHeaderHeight = kSectionHeaderHeight;
self.tableView.sectionFooterHeight = kSectionFooterHeight;
// With no header on first appearance, UITableView adds a 35 points space at
// the beginning of the table view. This space remains after this table view
// reloads with headers. Setting a small tableHeaderView avoids this.
self.tableView.tableHeaderView =
[[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, CGFLOAT_MIN)];
UITapGestureRecognizer* dismissKeyBoardTap = [[UITapGestureRecognizer alloc]
initWithTarget:self
action:@selector(dismissKeyboard)];
[self.view addGestureRecognizer:dismissKeyBoardTap];
[self.tableView registerClass:[UnitTypeValueFieldCell class]
forCellReuseIdentifier:kUnitTypeFieldCellIdentifier];
[self.tableView registerClass:[UnitTypeCell class]
forCellReuseIdentifier:kUnitTypeCellIdentifier];
self.tableView.delegate = self;
self.tableView.dataSource = self;
self.tableView.allowsSelection = NO;
[NSLayoutConstraint activateConstraints:@[
[_reportAnIssueButton.trailingAnchor
constraintLessThanOrEqualToAnchor:_tableViewFooterView.trailingAnchor
constant:-kReportAnIssueButtonTrailingOffset],
[_reportAnIssueButton.centerYAnchor
constraintEqualToAnchor:_tableViewFooterView.centerYAnchor],
[_reportAnIssueButton.leadingAnchor
constraintEqualToAnchor:_tableViewFooterView.leadingAnchor
constant:kReportAnIssueButtonLeadingOffset],
]];
self.tableView.accessibilityIdentifier = kUnitConversionTableViewIdentifier;
}
#pragma mark - Private
// Computes the new height based on the height `tableView`.
- (CGFloat)calculatePreferredContentHeight {
return self.tableView.contentSize.height + kTableViewHeightOffset;
}
- (void)closeButtonTapped:(UIButton*)sender {
[self.delegate didTapCloseUnitConversionController:self];
}
- (void)reportIssueButtonTapped:(UIButton*)sender {
[self.delegate didTapReportIssueUnitConversionController:self];
}
- (UIButton*)createReportIssueButton {
UIButton* button = [[UIButton alloc] init];
UIButtonConfiguration* buttonConfiguration =
[UIButtonConfiguration plainButtonConfiguration];
UIImage* reportAnIssueIcon = DefaultSymbolWithPointSize(
kExclamationMarkBubbleSymbol, kReportAnIssueIconSize);
[buttonConfiguration setImage:reportAnIssueIcon];
buttonConfiguration.imagePadding = kReportAnIssueButtonPadding;
buttonConfiguration.attributedTitle = [self
createAttributedStringWithTitle:
l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_REPORT_AN_ISSUE)
font:[UIFont preferredFontForTextStyle:
UIFontTextStyleFootnote]];
button.configuration = buttonConfiguration;
button.contentVerticalAlignment = UIControlContentVerticalAlignmentCenter;
[button setTintColor:[UIColor colorNamed:kBlueColor]];
[button addTarget:self
action:@selector(reportIssueButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
return button;
}
// Creates an attributed string object for a given title and font.
- (NSAttributedString*)createAttributedStringWithTitle:(NSString*)title
font:(UIFont*)font {
NSDictionary* attributes = @{NSFontAttributeName : font};
NSMutableAttributedString* attributedString =
[[NSMutableAttributedString alloc] initWithString:title
attributes:attributes];
return attributedString;
}
// Creates a button configuration that will be attached to
// UnitTypeTitleButton.
- (UIButtonConfiguration*)createUnitTypeTitleConfiguration {
UIButtonConfiguration* buttonConfiguration =
[UIButtonConfiguration plainButtonConfiguration];
buttonConfiguration.imagePlacement = NSDirectionalRectEdgeTrailing;
buttonConfiguration.imagePadding = kUnitTitlePadding;
buttonConfiguration.attributedTitle = [self
createAttributedStringWithTitle:[self unitTypeTitle]
font:[UIFont preferredFontForTextStyle:
UIFontTextStyleHeadline]];
return buttonConfiguration;
}
// Returns the UnitTypeTitleButton.
- (UIButton*)createUnitTypeTitleButton {
UIButtonConfiguration* buttonConfiguration =
[self createUnitTypeTitleConfiguration];
UIButton* unitTypeTitleButton = [UIButton buttonWithType:UIButtonTypeCustom];
unitTypeTitleButton.translatesAutoresizingMaskIntoConstraints = NO;
unitTypeTitleButton.configuration = buttonConfiguration;
UIImage* titleIcon =
DefaultSymbolWithPointSize(kChevronDownCircleFill, kTitleIconSize);
[unitTypeTitleButton
setImage:SymbolWithPalette(titleIcon,
@[
[UIColor colorNamed:kTextPrimaryColor],
[UIColor colorNamed:kGrey200Color]
])
forState:UIControlStateNormal];
[unitTypeTitleButton setTintColor:[UIColor colorNamed:kTextPrimaryColor]];
[unitTypeTitleButton setTitleColor:[UIColor colorNamed:kSolidBlackColor]
forState:UIControlStateNormal];
NSMutableArray* menuItemsArray = [NSMutableArray array];
__weak UnitConversionViewController* weakSelf = self;
std::vector<ios::provider::UnitType> unitTypes = [self unitTypes];
for (ios::provider::UnitType unitType : unitTypes) {
UIAction* action = [UIAction
actionWithTitle:[self titleForUnitType:unitType]
image:nil
identifier:nil
handler:^(UIAction* ac) {
weakSelf.unitType = unitType;
[weakSelf.mutator unitTypeDidChange:unitType
unitValue:[weakSelf unitValue]];
}];
[menuItemsArray addObject:action];
}
UIMenu* menu = [UIMenu menuWithChildren:menuItemsArray];
unitTypeTitleButton.menu = menu;
unitTypeTitleButton.accessibilityHint = l10n_util::GetNSString(
IDS_IOS_UNITS_MEASUREMENTS_ACCESSIBILITY_HINT_UNIT_TYPE_SELECTOR);
unitTypeTitleButton.showsMenuAsPrimaryAction = YES;
[unitTypeTitleButton sizeToFit];
return unitTypeTitleButton;
}
// Dismisses the keyboard, if it exist.
- (void)dismissKeyboard {
[self.view endEditing:NO];
}
// Computes the _targetUnitValueField's value by converting the unit value from
// sourceUnit to targetUnit.
- (void)computeTargetUnitValueField {
NSMeasurement* sourceUnitMeasurement =
[[NSMeasurement alloc] initWithDoubleValue:self.unitValue
unit:_sourceUnit];
DCHECK([sourceUnitMeasurement canBeConvertedToUnit:_targetUnit]);
NSMeasurement* targetUnitMeasurement =
[sourceUnitMeasurement measurementByConvertingToUnit:_targetUnit];
_targetUnitValueField = [NSString
localizedStringWithFormat:@"%g", targetUnitMeasurement.doubleValue];
}
// Returns the title string based on the unit type.
- (NSString*)titleForUnitType:(ios::provider::UnitType)unitType {
switch (unitType) {
case ios::provider::kUnitTypeArea:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_AREA);
case ios::provider::kUnitTypeInformationStorage:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_INFORMATION_STORAGE);
case ios::provider::kUnitTypeLength:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_LENGTH);
case ios::provider::kUnitTypeMass:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_MASS);
case ios::provider::kUnitTypeSpeed:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_SPEED);
case ios::provider::kUnitTypeTemperature:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_TEMPERATURE);
case ios::provider::kUnitTypeVolume:
return l10n_util::GetNSString(IDS_UNITS_MEASUREMENTS_VOLUME);
case ios::provider::kUnitTypeUnknown:
NOTREACHED();
}
return nil;
}
// Returns a UIMenu to the unitMenuButton of a given section.
- (UIMenu*)unitsMenuAtSection:(NSInteger)section {
const NSArray<NSArray<NSUnit*>*>* unitList = GetUnitsForType(_unitType);
NSMutableArray* menuItemsArray = [NSMutableArray array];
__weak UnitConversionViewController* weakSelf = self;
for (NSArray<NSUnit*>* units in unitList) {
NSMutableArray* unitsSubMenu = [NSMutableArray array];
for (NSUnit* unit in units) {
UIAction* unitAction = [UIAction
actionWithTitle:ios::provider::GetFormattedUnit(unit)
image:nil
identifier:nil
handler:^(UIAction* action) {
if (section == kSourceSection) {
[weakSelf.mutator sourceUnitDidChange:unit
targetUnit:weakSelf.targetUnit
unitValue:weakSelf.unitValue
unitType:weakSelf.unitType];
} else if (section == kTargetSection) {
[weakSelf.mutator targetUnitDidChange:unit
sourceUnit:weakSelf.sourceUnit
unitValue:weakSelf.unitValue
unitType:weakSelf.unitType];
}
}];
[unitsSubMenu addObject:unitAction];
}
[menuItemsArray addObject:[UIMenu menuWithTitle:@""
image:nil
identifier:nil
options:UIMenuOptionsDisplayInline
children:unitsSubMenu]];
}
return [UIMenu menuWithTitle:@""
image:nil
identifier:nil
options:UIMenuOptionsDisplayInline
children:menuItemsArray];
}
// Invoked when the value of the source unit textfield is changed.
- (void)sourceUnitFieldDidChange:(UITextField*)textField {
_sourceUnitValueField = [textField.text copy];
self.unitValue = [_sourceUnitValueField doubleValue];
[self.mutator sourceUnitValueFieldDidChange:_sourceUnitValueField
sourceUnit:_sourceUnit
targetUnit:_targetUnit];
}
// Invoked when the value of the target unit textfield is changed.
- (void)targetUnitFieldDidChange:(UITextField*)textField {
_targetUnitValueField = [textField.text copy];
[self.mutator targetUnitValueFieldDidChange:_targetUnitValueField
sourceUnit:_sourceUnit
targetUnit:_targetUnit];
}
// Reloads the tableView's row based on its section and row indentifiers.
- (void)reloadRow:(NSInteger)row
section:(NSInteger)section
reload:(BOOL)reload {
if (reload) {
NSIndexPath* path = [NSIndexPath indexPathForRow:row inSection:section];
[self.tableView reloadRowsAtIndexPaths:@[ path ]
withRowAnimation:UITableViewRowAnimationNone];
}
}
#pragma mark - UITableViewDataSource
- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView {
return 2;
}
- (NSInteger)tableView:(UITableView*)tableView
numberOfRowsInSection:(NSInteger)section {
return 2;
}
#pragma mark - UITableViewDelegate
- (UITableViewCell*)tableView:(UITableView*)tableView
cellForRowAtIndexPath:(NSIndexPath*)indexPath {
if (indexPath.row == kUnitTypeRow) {
UnitTypeCell* cell =
[tableView dequeueReusableCellWithIdentifier:kUnitTypeCellIdentifier];
cell.unitMenuButton.menu = [self unitsMenuAtSection:indexPath.section];
cell.unitMenuButton.accessibilityHint = l10n_util::GetNSString(
IDS_IOS_UNITS_MEASUREMENTS_ACCESSIBILITY_HINT_UNIT_SELECTOR);
cell.unitMenuButton.showsMenuAsPrimaryAction = YES;
NSString* unitMenuButtonTitle;
if (indexPath.section == kSourceSection) {
unitMenuButtonTitle = _formattedSourceUnit;
cell.unitMenuButton.accessibilityIdentifier =
kSourceUnitMenuButtonIdentifier;
} else if (indexPath.section == kTargetSection) {
unitMenuButtonTitle = _formattedTargetUnit;
cell.unitMenuButton.accessibilityIdentifier =
kTargetUnitMenuButtonIdentifier;
} else {
NOTREACHED();
}
UIButtonConfiguration* unitMenuButtonConfiguration =
cell.unitMenuButton.configuration;
unitMenuButtonConfiguration.attributedTitle = [self
createAttributedStringWithTitle:unitMenuButtonTitle
font:[UIFont
preferredFontForTextStyle:
UIFontTextStyleSubheadline]];
cell.unitMenuButton.configuration = unitMenuButtonConfiguration;
return cell;
}
if (indexPath.row == kUnitValueFieldRow) {
UnitTypeValueFieldCell* cell = [tableView
dequeueReusableCellWithIdentifier:kUnitTypeFieldCellIdentifier];
cell.unitValueTextField.delegate = self;
if (indexPath.section == kSourceSection) {
cell.unitValueTextField.text = _sourceUnitValueField;
cell.unitValueTextField.accessibilityIdentifier =
kSourceUnitFieldIdentifier;
[cell.unitValueTextField addTarget:self
action:@selector(sourceUnitFieldDidChange:)
forControlEvents:UIControlEventEditingChanged];
} else if (indexPath.section == kTargetSection) {
cell.unitValueTextField.text = _targetUnitValueField;
cell.unitValueTextField.accessibilityIdentifier =
kTargetUnitFieldIdentifier;
[cell.unitValueTextField addTarget:self
action:@selector(targetUnitFieldDidChange:)
forControlEvents:UIControlEventEditingChanged];
} else {
NOTREACHED();
}
return cell;
}
NOTREACHED();
}
#pragma mark - UnitConversionConsumer
- (void)updateSourceUnit:(NSUnit*)sourceUnit reload:(BOOL)reload {
_sourceUnit = sourceUnit;
_formattedSourceUnit = ios::provider::GetFormattedUnit(_sourceUnit);
[self reloadRow:kUnitTypeRow section:kSourceSection reload:reload];
}
- (void)updateTargetUnit:(NSUnit*)targetUnit reload:(BOOL)reload {
_targetUnit = targetUnit;
_formattedTargetUnit = ios::provider::GetFormattedUnit(_targetUnit);
[self reloadRow:kUnitTypeRow section:kTargetSection reload:reload];
}
- (void)updateSourceUnitValue:(double)sourceUnitValue reload:(BOOL)reload {
_sourceUnitValueField =
[NSString localizedStringWithFormat:@"%g", sourceUnitValue];
self.unitValue = sourceUnitValue;
[self reloadRow:kUnitValueFieldRow section:kSourceSection reload:reload];
}
- (void)updateTargetUnitValue:(double)targetUnitValue reload:(BOOL)reload {
_targetUnitValueField =
[NSString localizedStringWithFormat:@"%g", targetUnitValue];
[self reloadRow:kUnitValueFieldRow section:kTargetSection reload:reload];
}
- (void)updateUnitTypeTitle:(ios::provider::UnitType)unitType {
self.unitTypeTitle = [self titleForUnitType:unitType];
UIButtonConfiguration* buttonConfiguration =
_unitTypeTitleButton.configuration;
buttonConfiguration.attributedTitle = [self
createAttributedStringWithTitle:[self unitTypeTitle]
font:[UIFont preferredFontForTextStyle:
UIFontTextStyleHeadline]];
_unitTypeTitleButton.configuration = buttonConfiguration;
[self.tableView reloadData];
}
@end