Add base localization infrastructure and externalize strings (#670)

* Add base localization infrastructure and externalize strings

* Add Spanish localization scaffolding with translations

* Add machine translations for expanded locales

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-09-24 15:12:42 +02:00
committed by GitHub
co-authored by jack
parent de906cb97c
commit f5caa1751a
58 changed files with 4482 additions and 276 deletions
+89 -47
View File
@@ -17,16 +17,80 @@ struct LocationChannelsSheet: View {
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
static let description: LocalizedStringKey = "location_channels.description"
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = L10n.string(
"location_channels.error.invalid_geohash",
comment: "Error shown when a custom geohash is invalid"
)
static func meshTitle(_ count: Int) -> String {
let label = L10n.string(
"location_channels.mesh_label",
comment: "Label for the mesh channel row"
)
return rowTitle(label: label, count: count)
}
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
return rowTitle(label: level.displayName, count: count)
}
static func bookmarkTitle(geohash: String, count: Int) -> String {
return rowTitle(label: "#\(geohash)", count: count)
}
static func subtitlePrefix(geohash: String, coverage: String) -> String {
L10n.format(
"location_channels.subtitle_prefix",
comment: "Subtitle prefix showing geohash and coverage",
geohash,
coverage
)
}
static func subtitle(prefix: String, name: String?) -> String {
guard let name, !name.isEmpty else { return prefix }
return L10n.format(
"location_channels.subtitle_with_name",
comment: "Subtitle combining prefix and resolved location name",
prefix,
name
)
}
private static func rowTitle(label: String, count: Int) -> String {
let format = NSLocalizedString(
"location_channels.row_title",
comment: "List row title with participant count"
)
return NSString.localizedStringWithFormat(format as NSString, label, count) as String
}
}
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text("#location channels")
Text(Strings.title)
.font(.bitchatSystem(size: 18, design: .monospaced))
Spacer()
closeButton
}
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
@@ -34,7 +98,7 @@ struct LocationChannelsSheet: View {
switch manager.permissionState {
case LocationChannelManager.PermissionState.notDetermined:
Button(action: { manager.enableLocationChannels() }) {
Text("get location and my geohashes")
Text(Strings.requestPermissions)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
@@ -45,10 +109,10 @@ struct LocationChannelsSheet: View {
.buttonStyle(.plain)
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
VStack(alignment: .leading, spacing: 8) {
Text("location permission denied. enable in settings to use location channels.")
Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() }
Button(Strings.openSettings) { openSystemLocationSettings() }
.buttonStyle(.plain)
}
case LocationChannelManager.PermissionState.authorized:
@@ -109,7 +173,7 @@ struct LocationChannelsSheet: View {
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh)
isPresented = false
}
@@ -122,10 +186,11 @@ struct LocationChannelsSheet: View {
let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
let highlight = participantCount > 0
channelRow(
title: geohashTitleWithCount(for: channel),
title: Strings.levelTitle(for: channel.level, count: participantCount),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
@@ -149,7 +214,7 @@ struct LocationChannelsSheet: View {
sectionDivider
HStack(spacing: 8) {
ProgressView()
Text("finding nearby channels…")
Text(Strings.loadingNearby)
.font(.bitchatSystem(size: 12, design: .monospaced))
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -174,7 +239,7 @@ struct LocationChannelsSheet: View {
Button(action: {
openSystemLocationSettings()
}) {
Text("remove location access")
Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.frame(maxWidth: .infinity)
@@ -235,7 +300,7 @@ struct LocationChannelsSheet: View {
let isValid = validateGeohash(normalized)
Button(action: {
let gh = normalized
guard isValid else { customError = "invalid geohash"; return }
guard isValid else { customError = Strings.invalidGeohash; return }
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
manager.markTeleported(for: ch.geohash, true)
@@ -243,7 +308,7 @@ struct LocationChannelsSheet: View {
isPresented = false
}) {
HStack(spacing: 6) {
Text("teleport")
Text(Strings.teleport)
.font(.bitchatSystem(size: 14, design: .monospaced))
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
@@ -268,7 +333,7 @@ struct LocationChannelsSheet: View {
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("bookmarked")
Text(Strings.bookmarked)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
LazyVStack(spacing: 0) {
@@ -276,10 +341,11 @@ struct LocationChannelsSheet: View {
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)"
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
let name = bookmarks.bookmarkNames[gh]
let participantCount = viewModel.geohashParticipantCount(for: gh)
channelRow(
title: geohashHashTitleWithCount(gh),
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
@@ -352,12 +418,7 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary)
}
}
let subtitleFull: String = {
if let name = subtitleName, !name.isEmpty {
return subtitlePrefix + "" + name
}
return subtitlePrefix
}()
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
@@ -386,13 +447,6 @@ struct LocationChannelsSheet: View {
}
// MARK: - Helpers for counts
private func meshTitleWithCount() -> String {
// Count currently connected mesh peers (excluding self)
let meshCount = meshCount()
let noun = meshCount == 1 ? "person" : "people"
return "mesh [\(meshCount) \(noun)]"
}
private func meshCount() -> Int {
// Count mesh-connected OR mesh-reachable peers (exclude self)
let myID = viewModel.meshService.myPeerID
@@ -402,20 +456,6 @@ struct LocationChannelsSheet: View {
}
}
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
// Main list: keep level labels (block/neighborhood/city/province/region)
let count = viewModel.geohashParticipantCount(for: channel.geohash)
let noun = count == 1 ? "person" : "people"
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
}
private func geohashHashTitleWithCount(_ geohash: String) -> String {
// Bookmarked list: show the #geohash as the main label
let count = viewModel.geohashParticipantCount(for: geohash)
let noun = count == 1 ? "person" : "people"
return "#\(geohash) [\(count) \(noun)]"
}
private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false }
@@ -448,15 +488,15 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text("tor routing")
Text(Strings.torTitle)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text("hides your ip for location channels. recommended: on.")
Text(Strings.torSubtitle)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen))
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
@@ -473,13 +513,15 @@ extension LocationChannelsSheet {
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
let onLabel: LocalizedStringKey
let offLabel: LocalizedStringKey
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? "on" : "off")
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(configuration.isOn ? accent : .secondary)