mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user