diff --git a/Apps/Attendi/Sources/View Models/ContentViewModel.swift b/Apps/Attendi/Sources/View Models/ContentViewModel.swift index 9d7fef0..2601788 100644 --- a/Apps/Attendi/Sources/View Models/ContentViewModel.swift +++ b/Apps/Attendi/Sources/View Models/ContentViewModel.swift @@ -163,19 +163,29 @@ extension ContentView { } } - /// Returns the localized name of a locale for the locale picker. + /// Returns the localized language name of a locale, followed by the flag emoji of its region, for the locale picker. /// /// - Parameter locale: The locale to name. - /// - Returns: The name of the locale in the user's current locale, or its identifier when no name is available. + /// - Returns: The localized language name — in the user's current locale — followed by the region's flag emoji when the + /// locale has one, or the locale's identifier when the language has no localized name. func name( for locale: Locale ) -> String { - Locale - .current - .localizedString( - forIdentifier: locale.identifier - )?.localizedCapitalized + let name = locale + .language + .languageCode + .flatMap { Locale.current.localizedString(forLanguageCode: $0.identifier) }? + .localizedCapitalized ?? locale.identifier + + guard + let region = locale.region, + let flag = flag(for: region) + else { + return name + } + + return "\(name) \(flag)" } /// Handles the transcription of a processed recording, presenting it in the modal sheet. @@ -202,6 +212,35 @@ private extension ContentView.Model { // MARK: Methods + /// Returns the flag emoji of the given region, built from its two-letter code, or `nil` when the region has no such code. + /// + /// - Parameter region: The region to build a flag emoji for. + /// - Returns: The region's flag emoji, or `nil` when its identifier is not a two-letter code — a numeric UN M49 region, for example. + func flag( + for region: Locale.Region + ) -> String? { + let code = region.identifier + + guard + code.count == 2, + code.allSatisfy(\.isLetter) + else { + return nil + } + + var flag = "" + + for scalar in code.uppercased().unicodeScalars { + guard let indicator = Unicode.Scalar(Constant.Flag.base + scalar.value) else { + return nil + } + + flag.unicodeScalars.append(indicator) + } + + return flag + } + /// Starts listening to the events emitted by the preinstalling service, unless already listening: every started, cancelled, and /// failed download is posted to the ``ContentView/Model/notifier``, and a cancellation or failure clears the preinstalled locale /// — only when it is still the affected one, so a newer preinstallation is never forgotten — letting a re-pick retry. @@ -254,6 +293,12 @@ private extension ContentView.Model { /// The constant values used across the model. private enum Constant { + /// The flag constants. + enum Flag { + /// The distance from an ASCII uppercase letter to its Regional Indicator Symbol, used to build a flag emoji from a region code. + static let base: UInt32 = 0x1F1E6 - 0x41 + } + /// The symbol constants. enum Symbol { /// The system symbol of a cancelled download. diff --git a/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift b/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift index 64b2e4f..0202f62 100644 --- a/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift +++ b/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift @@ -226,6 +226,36 @@ struct ContentViewModelTests { } + // MARK: Name + + @MainActor + @Suite("Name") + struct Name { + + @Test + func `name pairs the localized language name with the region flag`() { + let model = Model(preinstaller: PreinstallingMock()) + + #expect(model.name(for: .english) == "\(languageName("en")) 🇺🇸") + #expect(model.name(for: .dutch) == "\(languageName("nl")) 🇳🇱") + } + + @Test + func `name omits the flag for a locale without a region`() { + let model = Model(preinstaller: PreinstallingMock()) + + #expect(!model.name(for: Locale(identifier: "en")).containsFlag) + } + + @Test + func `name omits the flag for a numeric region`() { + let model = Model(preinstaller: PreinstallingMock()) + + #expect(!model.name(for: Locale(identifier: "es-419")).containsFlag) + } + + } + } // MARK: - Constants @@ -238,3 +268,20 @@ private extension Locale { /// Another supported locale to preinstall in the tests. static let english = Locale(identifier: "en-US") } + +// MARK: - Helpers + +/// The capitalized, localized language name for a language code, mirroring how the model builds the language part of a locale's name. +/// +/// - Parameter code: The language code to name. +/// - Returns: The localized language name in the current locale, capitalized. +private func languageName(_ code: String) -> String { + (Locale.current.localizedString(forLanguageCode: code) ?? code).localizedCapitalized +} + +private extension String { + /// Whether the string contains a Regional Indicator Symbol scalar — part of a flag emoji. + var containsFlag: Bool { + unicodeScalars.contains { $0.value >= 0x1F1E6 && $0.value <= 0x1F1FF } + } +}