Release 1.2.1: iCloud sync improvements + ASO multilingual metadata

iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-03-25 11:54:35 +01:00
parent 7cb5f92cf4
commit 10f6d0ca20
108 changed files with 4069 additions and 238 deletions
@@ -46,58 +46,23 @@ enum CurrencyFormatter {
return formatter.currencySymbol ?? code
}
/// Parses a user-typed numeric string using digit-position-based separator detection.
/// Locale-independent: a separator followed by 1-2 digits at the end is decimal,
/// otherwise it is a thousands separator.
/// Parses a user-typed numeric string accepting both `.` and `,` as decimal/grouping separators.
/// The parser is intentionally permissive to avoid turning decimal input into huge integers.
static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? {
let stripped = string
.replacingOccurrences(of: currencySymbol, with: "")
.trimmingCharacters(in: .whitespaces)
.filter { $0.isNumber || $0 == "." || $0 == "," }
.replacingOccurrences(of: "\u{00A0}", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" }
guard !stripped.isEmpty else { return nil }
let hasDot = stripped.contains(".")
let hasComma = stripped.contains(",")
let isNegative = stripped.hasPrefix("-")
let unsigned = stripped.replacingOccurrences(of: "-", with: "")
guard !unsigned.isEmpty else { return nil }
let normalized: String
if hasDot && hasComma {
// Both separators: the one appearing last is the decimal
let lastDot = stripped.lastIndex(of: ".")!
let lastComma = stripped.lastIndex(of: ",")!
if lastComma > lastDot {
// e.g. 1.234,56 1234.56
normalized = stripped
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: ".")
} else {
// e.g. 1,234.56 1234.56
normalized = stripped.replacingOccurrences(of: ",", with: "")
}
} else if hasComma {
// Only comma: decimal if exactly 2 parts and last has 2 digits
let parts = stripped.components(separatedBy: ",")
if parts.count == 2 && (parts.last?.count ?? 0) <= 2 {
// e.g. 408857,62 408857.62
normalized = stripped.replacingOccurrences(of: ",", with: ".")
} else {
// e.g. 408,857 or 1,234,567 thousands
normalized = stripped.replacingOccurrences(of: ",", with: "")
}
} else if hasDot {
// Only dot: decimal if exactly 2 parts and last has 2 digits
let parts = stripped.components(separatedBy: ".")
if parts.count == 2 && (parts.last?.count ?? 0) <= 2 {
// e.g. 408857.62 already correct
normalized = stripped
} else {
// e.g. 408.857 or 1.234.567 thousands
normalized = stripped.replacingOccurrences(of: ".", with: "")
}
} else {
normalized = stripped
}
let normalizedUnsigned = normalizeNumericInput(unsigned)
let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
@@ -105,6 +70,62 @@ enum CurrencyFormatter {
return formatter.number(from: normalized)?.decimalValue
}
private static func normalizeNumericInput(_ input: String) -> String {
let hasDot = input.contains(".")
let hasComma = input.contains(",")
if hasDot && hasComma {
// Both separators present: the last one is considered decimal.
let lastDot = input.lastIndex(of: ".")!
let lastComma = input.lastIndex(of: ",")!
if lastComma > lastDot {
return input
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: ".")
}
return input.replacingOccurrences(of: ",", with: "")
}
if hasDot {
return normalizeSingleSeparator(input, separator: ".")
}
if hasComma {
return normalizeSingleSeparator(input, separator: ",")
}
return input
}
private static func normalizeSingleSeparator(_ input: String, separator: Character) -> String {
let parts = input.split(separator: separator, omittingEmptySubsequences: false)
guard parts.count > 1 else { return input }
if parts.count == 2 {
// A single separator is treated as decimal (e.g. 533.595).
return "\(parts[0]).\(parts[1])"
}
let integerParts = Array(parts.dropLast())
let fractionPart = String(parts.last ?? "")
// If all groups follow a strict thousands pattern and the last one has 3 digits,
// prefer grouping-only interpretation (e.g. 1.234.567).
if looksLikeGroupedThousands(integerParts), fractionPart.count == 3 {
return input.replacingOccurrences(of: String(separator), with: "")
}
// Otherwise, treat the last separator as decimal and previous ones as grouping.
let integer = integerParts.joined()
return "\(integer).\(fractionPart)"
}
private static func looksLikeGroupedThousands(_ groups: [Substring]) -> Bool {
guard let first = groups.first, !first.isEmpty, first.count <= 3 else { return false }
guard groups.count >= 2 else { return false }
return groups.dropFirst().allSatisfy { $0.count == 3 }
}
/// Formats a decimal for display in an input field (no grouping separator).
static func formatForInput(_ decimal: Decimal, currencyCode: String) -> String {
let formatter = NumberFormatter()