feat(apple): generate type-safe L10n accessors and migrate all key literals

Replaces every stringly-typed localization key in the Apple app with
compile-time-checked accessors generated from localization/strings.json.
A mistyped key is now a build error instead of a silent fallback to the
raw key at runtime. The runtime path is unchanged: plain keys are
String.LocalizationValue constants resolved with String(localized:) and
Apple's String Catalog still does the lookup; keys with arguments become
typed, named functions applying args through String(format:).

Generator: new renderSwiftAccessors emits apple/VniDrop/Generated/L10n.swift,
wired into generate. Renamed generic arg1/arg2 tokens on four keys to
semantic names (receiver, transferName, deviceId) and updated their context
notes; positional output is unchanged so .xcstrings (bar the 4 comments)
and the Android XML regenerate identical.

Migration: every key-carrying value flipped to String.LocalizationValue
end to end, resolved only at the leaf. Zero key literals and zero
LocalizedStringKey remain in app or test code. macOS build passes; iOS
test run pending.
This commit is contained in:
2026-07-23 17:12:43 +02:00
parent 5939489432
commit ef42875ddb
28 changed files with 4465 additions and 395 deletions

View File

@@ -2,7 +2,7 @@ import SwiftUI
enum ReceiveMethodAvailability { case available, unavailable, hidden }
/// Invitation acquisition actions shared by the native Apple feature models.
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
@MainActor
protocol ReceiveInvitationActions: AnyObject {
var fileAvailability: ReceiveMethodAvailability { get }
@@ -23,25 +23,25 @@ struct ReceiveMethodPanel: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
MethodRow(
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
icon: "doc", titleKey: L10n.Receive.methodFile, descKey: L10n.Receive.methodFileDescription,
availability: actions.fileAvailability
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
if actions.qrAvailability != .hidden {
MethodRow(
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
icon: "qrcode.viewfinder", titleKey: L10n.Receive.methodScan, descKey: L10n.Receive.methodScanDescription,
availability: actions.qrAvailability
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
}
if actions.nfcAvailability != .hidden {
MethodRow(
icon: "wave.3.right",
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil,
titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription,
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
) {
model.setWaitingForNfc(true)
@@ -58,8 +58,8 @@ private struct MethodRow: View {
@Environment(\.vniColors) private var colors
let icon: String
var titleOverride: String? = nil
let titleKey: String
let descKey: String
let titleKey: String.LocalizationValue
let descKey: String.LocalizationValue
let availability: ReceiveMethodAvailability
let onTap: () -> Void
@@ -74,13 +74,13 @@ private struct MethodRow: View {
if let titleOverride {
Text(titleOverride).font(VniType.bodyLarge)
} else {
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
Text(String(localized: titleKey)).font(VniType.bodyLarge)
}
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
Spacer()
if availability == .unavailable {
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
}
}
.padding(16)
@@ -102,7 +102,7 @@ struct InvitationReviewPanel: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge)
if state.isInspecting {
ProgressView().frame(maxWidth: .infinity).padding(40)
}
@@ -110,16 +110,16 @@ struct InvitationReviewPanel: View {
let metadata = inspection.metadata
VStack(alignment: .leading, spacing: 8) {
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
Text("\(metadata.fileCount) \(String(localized: L10n.Metadata.files).lowercased()) · \(formatBytes(metadata.totalSize))")
.foregroundStyle(colors.foregroundLighter)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
Field(label: String(localized: "field_receiver_name"),
Field(label: String(localized: L10n.Field.receiverName),
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
Text(state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
.font(VniType.bodySmall)
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
@@ -127,11 +127,11 @@ struct InvitationReviewPanel: View {
let progressId = state.activeReceiveTransferId
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)
} else {
PrimaryButton(
title: String(localized: "button_receive"), action: model.receive,
title: String(localized: L10n.Button.receive), action: model.receive,
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
)
}

View File

@@ -133,7 +133,7 @@ final class ReceiveModel: ObservableObject {
case .success:
state.historyDeleteTarget = nil
state.isDeletingHistory = false
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
case .failure(let error):
state.isDeletingHistory = false
@@ -173,9 +173,9 @@ final class ReceiveModel: ObservableObject {
resetAcquisition()
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(UiMessage(
text: .resource("receive_completed"),
text: .resource(L10n.Receive.completed),
tone: .success,
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
))
case .failure(let error):
@@ -192,8 +192,8 @@ final class ReceiveModel: ObservableObject {
messages.tryShow(UiMessage(
text: uiText,
tone: .error,
actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil,
onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil
actionLabel: .resource(L10n.Button.retry),
onAction: { self.receive() }
))
}
}
@@ -222,14 +222,14 @@ final class ReceiveModel: ObservableObject {
Task {
let result = await fileSystemService.revealReceiveFolder(folder)
if case .failure = result {
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error))
}
}
}
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
state.isAcquisitionOpen = true
state.ticket = ticket
state.method = method

View File

@@ -22,17 +22,17 @@ struct ReceiveScreen: View {
history
}
}
.navigationTitle(Text(LocalizedStringKey("receive_title")))
.navigationTitle(Text(String(localized: L10n.Receive.title)))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus")
Label(String(localized: L10n.Button.receiveFiles), systemImage: "plus")
}
}
if !deletable.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestClearHistory) {
Label(String(localized: "receive_clear_history"), systemImage: "trash")
Label(String(localized: L10n.Receive.clearHistory), systemImage: "trash")
}
}
}
@@ -50,11 +50,11 @@ struct ReceiveScreen: View {
}
}
.alert(
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
Text(String(localized: clearAllPending ? L10n.Receive.clearHistoryTitle : L10n.Receive.deleteHistoryTitle)),
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
) {
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete)
Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer),
role: .destructive, action: model.confirmHistoryDelete)
} message: {
historyDeleteMessage
@@ -74,27 +74,27 @@ struct ReceiveScreen: View {
Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId)
} label: {
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
Label(String(localized: L10n.Button.deleteTransfer), systemImage: "trash")
}
}
}
}
} header: {
Text(LocalizedStringKey("receive_history_title"))
Text(String(localized: L10n.Receive.historyTitle))
} footer: {
Text(LocalizedStringKey("receive_new_subtitle"))
Text(String(localized: L10n.Receive.newSubtitle))
}
}
}
private var emptyState: some View {
ContentUnavailableView {
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
Label(String(localized: L10n.Receive.emptyTitle), systemImage: "tray.and.arrow.down")
} description: {
Text(LocalizedStringKey("receive_empty_body"))
Text(String(localized: L10n.Receive.emptyBody))
} actions: {
Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus")
Label(String(localized: L10n.Button.receiveFiles), systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
@@ -107,10 +107,10 @@ struct ReceiveScreen: View {
private var historyDeleteMessage: some View {
if let target = model.state.historyDeleteTarget {
if target == .all {
Text(LocalizedStringKey("receive_clear_history_description"))
Text(String(localized: L10n.Receive.clearHistoryDescription))
} else {
Text(String(format: String(localized: "receive_delete_history_description"),
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
Text(L10n.Receive.deleteHistoryDescription(
transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
}
}
}
@@ -134,7 +134,7 @@ private struct ReceiveTransferRow: View {
.frame(width: 40, height: 40)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) {
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer))
.font(.body).lineLimit(1)
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
.font(.caption).foregroundStyle(.secondary)