Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Ti.Local.parseDecimal() method #11683

Merged
merged 4 commits into from
Jul 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package ti.modules.titanium.locale;

import java.util.Locale;
import java.text.NumberFormat;

import org.appcelerator.kroll.KrollModule;
import org.appcelerator.kroll.annotations.Kroll;
Expand Down Expand Up @@ -82,6 +83,39 @@ public String formatTelephoneNumber(String telephoneNumber)
return PhoneNumberUtils.formatNumber(telephoneNumber);
}

@Kroll.method
public double parseDecimal(String text, @Kroll.argument(optional = true) String localeString)
{
double result = Double.NaN;
try {
// Create a number format parser using given locale if provided or current locale.
Locale locale = TiPlatformHelper.getInstance().getLocale(localeString);
NumberFormat numberFormat;
if (locale != null) {
numberFormat = NumberFormat.getInstance(locale);
} else {
numberFormat = NumberFormat.getInstance();
}

// Enable thousands separator parsing support. (ex: "1,234,567")
numberFormat.setGroupingUsed(true);

// Remove leading spaces and plus sign. Number format will fail to parse if there.
text = text.trim();
if ((text != null) && text.startsWith("+")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't need the null check since you're already trimming without checking above. I assume our bridge layer enforces non-null if the argument is not marked optional? If not, the null check needs to move to the very top.

text = text.substring(1);
}

// Attempt to parse a decimal value from given string.
Number number = numberFormat.parse(text);
if (number != null) {
result = number.doubleValue();
}
} catch (Exception ex) {
}
return result;
}

@Kroll.method
@Kroll.setProperty
public void setLanguage(String language)
Expand Down
3 changes: 3 additions & 0 deletions apidoc/Global/String/String.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ methods:
summary: |
Formats a number into the decimal format, including decimal symbol, of the locale
configured for the system.
description: |
The localized string created by this method can be parsed be parsed back to a number
via the <Titanium.Locale.parseDecimal> method.
returns:
type: String
summary: |
Expand Down
31 changes: 31 additions & 0 deletions apidoc/Titanium/Locale/Locale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,37 @@ methods:
Ti.API.info(String.format(L('phrase'), L('greeting', i18nMissingMsg), L('signoff', i18nMissingMsg)));
```

- name: parseDecimal
summary: Parses a number from the given string using the current or given locale.
platforms: [android, iphone, ipad]
returns:
type: Number
summary: Returns the parsed number. Returns `NaN` (Not-a-Number) if given string is invalid.
parameters:
- name: text
summary: The string to be parsed.
type: String

- name: locale
summary: |
Locale, as a combination of ISO 2-letter language and country codes. For example,
`en-US` or `en-GB`. See the
[ISO 639-1](http://en.wikipedia.org/wiki/ISO_639-1) and
[ISO 3166-1 alpha-2](http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements)
sections of wikipedia for reference.
type: String
optional: true
examples:
- title: Parsing Localized Numeric Strings
example: |
// You can convert to/from a localized numeric string like this.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a fenced code block now:

// this is some example code

var numericString = String.formatDecimal(1234.5);
var numericValue = Ti.Locale.parseDecimal(numericString);

// The numbers returned will be the same for the 2 different locales.
var number1 = Ti.Locale.parseDecimal('1,234,567.8', 'en-US');
var number2 = Ti.Locale.parseDecimal('1.234.567,8', 'de-DE');

events:
- name: change
summary: Fired when the device locale changes.
Expand Down
4 changes: 4 additions & 0 deletions iphone/Classes/LocaleModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ JSExportAs(getString,
-(NSString *)getString
: (NSString *)key withHint
: (id)hint);
JSExportAs(parseDecimal,
-(NSNumber *)parseDecimal
: (NSString *)text withLocaleId
: (id)localeId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I'm a bit conflicted over the use of id for the second optional argument. The JSC API doesn't give us a straight-forward way to handle optional arguments. I just tested, and if you declare it as an NSString* (which it should be), but you don't provide the arg - it converts the undefined value to "undefined" as a String. Which is clearly an annoyance (and probably why I did the same above for getString's hint arg).

So I guess we have to either declare as id like this so undefined becomes nil - or not even define the optional argument and instead pull it out of [JSContext.currentArguments].

- (void)setLanguage:(NSString *)language;

@end
Expand Down
29 changes: 29 additions & 0 deletions iphone/Classes/LocaleModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ - (NSString *)getLocaleCurrencySymbol:(NSString *)locale
return [[[[NSLocale alloc] initWithLocaleIdentifier:locale] autorelease] objectForKey:NSLocaleCurrencySymbol];
}

- (NSNumber *)parseDecimal:(NSString *)text withLocaleId:(id)localeId
{
// Fetch optional locale string argument. (ex: "en-US")
NSString *localeStringId = nil;
if ((localeId != nil) && [localeId isKindOfClass:[NSString class]]) {
localeStringId = (NSString *)localeId;
}

// Acquire requested locale if provided or current locale.
NSLocale *locale = nil;
if (localeStringId != nil) {
locale = [[[NSLocale alloc] initWithLocaleIdentifier:localeStringId] autorelease];
}
if (locale == nil) {
locale = [NSLocale currentLocale];
}

// Remove localized thousands separators from string if they exist. NSScanner fails to parse them.
NSString *thousandsSeparator = [locale objectForKey:NSLocaleGroupingSeparator];
text = [text stringByReplacingOccurrencesOfString:thousandsSeparator withString:@""];

// Attempt to parse a number from given text. Return not-a-number if failed.
NSScanner *scanner = [NSScanner localizedScannerWithString:text];
[scanner setLocale:locale];
double value = NAN;
[scanner scanDouble:&value];
return [NSNumber numberWithDouble:value];
}

- (void)setLanguage:(NSString *)locale
{
[TiLocale setLocale:locale];
Expand Down
118 changes: 118 additions & 0 deletions tests/Resources/ti.locale.addontest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Appcelerator Titanium Mobile
* Copyright (c) 2015-Present by Axway, Inc. All Rights Reserved.
* Licensed under the terms of the Apache Public License
* Please see the LICENSE included with this distribution for details.
*/
/* eslint-env mocha */
/* eslint no-unused-expressions: "off" */
'use strict';
var should = require('./utilities/assertions');

describe('Titanium.Locale', function () {
describe('#parseDecimal()', () => {
it('compared with String.formatDecimal()', () => {
should(Ti.Locale.parseDecimal).be.a.Function();

const numericValue = 1234567.8;

let numericString = String.formatDecimal(numericValue);
let parsedValue = Ti.Locale.parseDecimal(numericString);
should(parsedValue).be.a.Number();
should(Math.abs(parsedValue - numericValue)).be.lessThan(Number.EPSILON);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat trick - never knew about the use of EPSILON to compare floats.

May be easier to write this as:

should(parsedValue).be.approximately(numericValue, Number.EPSILON);

See https://shouldjs.github.io/#assertion-approximately


numericString = String.formatDecimal(numericValue, 'de-DE');
parsedValue = Ti.Locale.parseDecimal(numericString, 'de-DE');
should(Math.abs(parsedValue - numericValue)).be.lessThan(Number.EPSILON);

numericString = String.formatDecimal(numericValue, 'fr-FR');
parsedValue = Ti.Locale.parseDecimal(numericString, 'fr-FR');
should(Math.abs(parsedValue - numericValue)).be.lessThan(Number.EPSILON);

numericString = String.formatDecimal(numericValue, 'ar-EG');
parsedValue = Ti.Locale.parseDecimal(numericString, 'ar-EG');
should(Math.abs(parsedValue - numericValue)).be.lessThan(Number.EPSILON);
});

it('localized values', () => {
let result = Ti.Locale.parseDecimal('1,234,567.8', 'en-US');
should(Math.abs(result - 1234567.8)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('1.234.567,8', 'de-DE');
should(Math.abs(result - 1234567.8)).be.lessThan(Number.EPSILON);

// France uses non-breaking unicode spaces for thousands separator.
result = Ti.Locale.parseDecimal('1\u00A0234\u00A0567,8', 'fr-FR');
should(Math.abs(result - 1234567.8)).be.lessThan(Number.EPSILON);
});

it('various values', () => {
let result = Ti.Locale.parseDecimal('0', 'en-US');
should(Math.abs(result)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('0.', 'en-US');
should(Math.abs(result)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('.0', 'en-US');
should(Math.abs(result)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('00.00123', 'en-US');
should(Math.abs(result - 0.00123)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('+0', 'en-US');
should(Math.abs(result)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('-0', 'en-US');
should(Math.abs(result)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('+1234.5', 'en-US');
should(Math.abs(result - 1234.5)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('-1234.5', 'en-US');
should(Math.abs(result + 1234.5)).be.lessThan(Number.EPSILON);
});

it('scientific values', () => {
let result = Ti.Locale.parseDecimal('1.2E+3', 'en-US');
should(Math.abs(result - 1200.0)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('-1.2E+3', 'en-US');
should(Math.abs(result + 1200.0)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('1.2E-3', 'en-US');
should(Math.abs(result - 0.0012)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal('-1.2E-3', 'en-US');
should(Math.abs(result + 0.0012)).be.lessThan(Number.EPSILON);
});

it('padded with spaces', () => {
let result = Ti.Locale.parseDecimal(' 123 ', 'en-US');
should(Math.abs(result - 123)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal(' +123 ', 'en-US');
should(Math.abs(result - 123)).be.lessThan(Number.EPSILON);

result = Ti.Locale.parseDecimal(' -123 ', 'en-US');
should(Math.abs(result + 123)).be.lessThan(Number.EPSILON);
});

it('NaN', () => {
let result = Ti.Locale.parseDecimal('ThisShouldFail');
should(result).be.a.Number();
should(result).be.eql(Number.NaN);

result = Ti.Locale.parseDecimal('');
should(result).be.eql(Number.NaN);

result = Ti.Locale.parseDecimal(' ');
should(result).be.eql(Number.NaN);

result = Ti.Locale.parseDecimal(null);
should(result).be.eql(Number.NaN);

result = Ti.Locale.parseDecimal(undefined);
should(result).be.eql(Number.NaN);
});
});
});