From 7576e58389b729973a1f1f889239190cc2ee3cd7 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 6 Oct 2026 16:30:23 -0500 Subject: [PATCH] Add persistent Spanish language setting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../main/java/app/fieldwatch/alert/Alerter.kt | 41 +- .../java/app/fieldwatch/data/ConfigStore.kt | 21 +- .../main/java/app/fieldwatch/domain/Models.kt | 8 + .../java/app/fieldwatch/radio/ScanService.kt | 37 +- .../main/java/app/fieldwatch/ui/AppText.kt | 699 ++++++++++++++++++ .../java/app/fieldwatch/ui/ClassGlyphs.kt | 6 +- .../java/app/fieldwatch/ui/FieldwatchAppUi.kt | 28 +- .../java/app/fieldwatch/ui/LiveChromeTour.kt | 2 +- .../java/app/fieldwatch/ui/NestedTabChrome.kt | 2 +- .../ui/component/FieldwatchDropdownField.kt | 2 +- .../ui/component/FieldwatchOutlinedField.kt | 2 +- .../fieldwatch/ui/component/SectionCard.kt | 2 +- .../fieldwatch/ui/component/SitPathCanvas.kt | 10 +- .../fieldwatch/ui/component/StickyHeight.kt | 2 +- .../app/fieldwatch/ui/component/Widgets.kt | 2 +- .../fieldwatch/ui/screen/CandidatesScreen.kt | 4 +- .../ui/screen/DecodeFieldsScreen.kt | 8 +- .../ui/screen/DeviceDetailScreen.kt | 16 +- .../app/fieldwatch/ui/screen/FiltersScreen.kt | 2 +- .../app/fieldwatch/ui/screen/FleetsScreen.kt | 10 +- .../app/fieldwatch/ui/screen/HuntScreen.kt | 7 +- .../app/fieldwatch/ui/screen/LiveScreens.kt | 9 +- .../ui/screen/RadioBookmarksScreen.kt | 8 +- .../app/fieldwatch/ui/screen/ReportsScreen.kt | 2 +- .../fieldwatch/ui/screen/ScanStatusScreen.kt | 4 +- .../fieldwatch/ui/screen/SettingsScreen.kt | 18 +- .../data/ConfigStoreDefaultsTest.kt | 40 + .../fieldwatch/domain/SettingsExchangeTest.kt | 15 + .../java/app/fieldwatch/ui/AppTextTest.kt | 16 + 29 files changed, 937 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/app/fieldwatch/ui/AppText.kt create mode 100644 app/src/test/java/app/fieldwatch/data/ConfigStoreDefaultsTest.kt create mode 100644 app/src/test/java/app/fieldwatch/ui/AppTextTest.kt diff --git a/app/src/main/java/app/fieldwatch/alert/Alerter.kt b/app/src/main/java/app/fieldwatch/alert/Alerter.kt index a57a570..6265336 100644 --- a/app/src/main/java/app/fieldwatch/alert/Alerter.kt +++ b/app/src/main/java/app/fieldwatch/alert/Alerter.kt @@ -24,13 +24,16 @@ import androidx.core.app.NotificationCompat import app.fieldwatch.MainActivity import app.fieldwatch.R import app.fieldwatch.domain.AlertVoiceWhat +import app.fieldwatch.domain.AppLanguage import app.fieldwatch.domain.Fleet import app.fieldwatch.domain.MacUtil import app.fieldwatch.domain.RadioBookmarks import app.fieldwatch.domain.Sighting +import app.fieldwatch.domain.label import app.fieldwatch.domain.WatchTarget import app.fieldwatch.domain.spokenWatchPhrase import app.fieldwatch.domain.testWatchPhrase +import app.fieldwatch.ui.translateAppText import java.util.Locale import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -47,6 +50,8 @@ class Alerter(private val context: Context) { private var tts: TextToSpeech? = null private var ttsReady = false private var speaking = false + private var legacyChannelCleanupDone = false + private var channelLanguage: AppLanguage? = null private var utteranceStarted = false private var ttsSpeakTries = 0 private var pendingSpeak: String? = null @@ -63,7 +68,7 @@ class Alerter(private val context: Context) { val flashes: SharedFlow = _flashes.asSharedFlow() init { - ensureChannel() + ensureChannel(AppLanguage.ENGLISH) } fun checkLive( @@ -78,11 +83,12 @@ class Alerter(private val context: Context) { visibleOnLive: (Sighting) -> Boolean = { !it.gone }, arrivalsOnly: Boolean = false, demoMode: Boolean = false, + language: AppLanguage = AppLanguage.ENGLISH, ) { if (!alertsOn || watchlist.isEmpty()) return fireNewAppearances( devices, fleets, watchlist, beepOn, voiceOn, voiceWhat, shadeOn, - visibleOnLive, arrivalsOnly, demoMode, + visibleOnLive, arrivalsOnly, demoMode, language, ) } @@ -126,6 +132,7 @@ class Alerter(private val context: Context) { visibleOnLive: (Sighting) -> Boolean, arrivalsOnly: Boolean, demoMode: Boolean, + language: AppLanguage, ) { val now = System.currentTimeMillis() val onAir = devices.filter { !it.gone } @@ -151,7 +158,7 @@ class Alerter(private val context: Context) { device.mac, demoMode, ) - if (shadeOn && target.notify) notify(label, device, demoMode) + if (shadeOn && target.notify) notify(label, device, demoMode, language) if (target.vibrate) vibrate() if (beepOn && !beepFired) { beep(holdFocus = !voiceOn) @@ -182,7 +189,8 @@ class Alerter(private val context: Context) { return now - spanStart <= NEW_WINDOW_MS } - private fun notify(label: String, device: Sighting, demoMode: Boolean) { + private fun notify(label: String, device: Sighting, demoMode: Boolean, language: AppLanguage) { + ensureChannel(language) val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra(EXTRA_DEVICE_KEY, device.key) @@ -195,11 +203,11 @@ class Alerter(private val context: Context) { ) val note = NotificationCompat.Builder(context, CHANNEL) .setSmallIcon(R.drawable.ic_stat_fieldwatch) - .setContentTitle("Fieldwatch watchlist") + .setContentTitle(translateAppText("Fieldwatch watchlist", language)) .setContentText("$label ${device.rssi} dBm ${MacUtil.screenMac(device.mac, demoMode)}") .setStyle( NotificationCompat.BigTextStyle().bigText( - "$label appeared\n${device.kind} ${MacUtil.screenMac(device.mac, demoMode)}\n${device.rssi} dBm ${MacUtil.redactMacIn(device.displayName, device.mac, demoMode)}", + "$label ${translateAppText("appeared", language)}\n${translateAppText(device.kind.label(), language)} ${MacUtil.screenMac(device.mac, demoMode)}\n${device.rssi} dBm ${MacUtil.redactMacIn(device.displayName, device.mac, demoMode)}", ), ) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -487,18 +495,29 @@ class Alerter(private val context: Context) { vibrator.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 80, 60, 80), -1)) } - private fun ensureChannel() { + private fun ensureChannel(language: AppLanguage) { if (Build.VERSION.SDK_INT < 26) return - runCatching { manager.deleteNotificationChannel("fieldwatch_watch_v2") } - if (manager.getNotificationChannel(CHANNEL) != null) return + if (!legacyChannelCleanupDone) { + runCatching { manager.deleteNotificationChannel("fieldwatch_watch_v2") } + legacyChannelCleanupDone = true + } + if (channelLanguage == language) return manager.createNotificationChannel( - NotificationChannel(CHANNEL, "Watchlist", NotificationManager.IMPORTANCE_HIGH).apply { - description = "Appearing signatures and devices on your watchlist. Beep is played separately." + NotificationChannel( + CHANNEL, + translateAppText("Watchlist alerts", language), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = translateAppText( + "Appearing signatures and devices on your watchlist. Beep is played separately.", + language, + ) enableVibration(true) enableLights(true) setSound(null, null) }, ) + channelLanguage = language } companion object { diff --git a/app/src/main/java/app/fieldwatch/data/ConfigStore.kt b/app/src/main/java/app/fieldwatch/data/ConfigStore.kt index 7b24813..ed82eba 100644 --- a/app/src/main/java/app/fieldwatch/data/ConfigStore.kt +++ b/app/src/main/java/app/fieldwatch/data/ConfigStore.kt @@ -31,6 +31,15 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File +internal fun restoreConfigDefaults(current: PersistedConfig, defaults: PersistedConfig): PersistedConfig = + defaults.copy( + settings = defaults.settings.copy( + disclaimerAccepted = current.settings.disclaimerAccepted, + disclaimerRev = current.settings.disclaimerRev, + language = current.settings.language, + ), + ) + class ConfigStore(context: Context) { private val file = File(context.filesDir, "config.json") private val json = Json { @@ -92,17 +101,7 @@ class ConfigStore(context: Context) { } suspend fun restoreDefaults() { - val accepted = _config.value.settings.disclaimerAccepted - val rev = _config.value.settings.disclaimerRev - update { - val fresh = seed() - fresh.copy( - settings = fresh.settings.copy( - disclaimerAccepted = accepted, - disclaimerRev = rev, - ), - ) - } + update { current -> restoreConfigDefaults(current, seed()) } } suspend fun importFleets(incoming: List): SignatureImportResult = mutex.withLock { diff --git a/app/src/main/java/app/fieldwatch/domain/Models.kt b/app/src/main/java/app/fieldwatch/domain/Models.kt index e918af7..7133341 100644 --- a/app/src/main/java/app/fieldwatch/domain/Models.kt +++ b/app/src/main/java/app/fieldwatch/domain/Models.kt @@ -33,6 +33,12 @@ enum class ScanIntensity { SAVER, BALANCED, PERFORMANCE } @Serializable enum class LogFormat { CSV, JSON } +@Serializable +enum class AppLanguage { + ENGLISH, + SPANISH, +} + @Serializable enum class FilterLogic { AND, OR } @@ -648,6 +654,8 @@ data class AppSettings( val signatureListSort: SignatureListSort = SignatureListSort.NAME, /** By class: hide class headers with 0 radios. Off = show all (zeros stay). */ val outlineHideEmpty: Boolean = false, + /** Language used by the app UI. Missing from older configs means English. */ + val language: AppLanguage = AppLanguage.ENGLISH, ) const val DISCLAIMER_REV = 3 diff --git a/app/src/main/java/app/fieldwatch/radio/ScanService.kt b/app/src/main/java/app/fieldwatch/radio/ScanService.kt index 1adb5bc..39b3517 100644 --- a/app/src/main/java/app/fieldwatch/radio/ScanService.kt +++ b/app/src/main/java/app/fieldwatch/radio/ScanService.kt @@ -18,6 +18,7 @@ import app.fieldwatch.domain.Observation import app.fieldwatch.domain.RadioKind import app.fieldwatch.domain.ScanIntensity import app.fieldwatch.domain.detectionPolicy +import app.fieldwatch.ui.translateAppText import android.os.SystemClock import android.util.Log import kotlinx.coroutines.CancellationException @@ -37,6 +38,7 @@ class ScanService : LifecycleService() { private var bleStartJob: Job? = null private var lastIntensity: ScanIntensity? = null private var lastNotifAt = 0L + private var channelLanguage: app.fieldwatch.domain.AppLanguage? = null private val inbound = Channel(512, BufferOverflow.DROP_OLDEST) private val publishGate = Any() private var publishJob: Job? = null @@ -45,8 +47,8 @@ class ScanService : LifecycleService() { override fun onCreate() { super.onCreate() - ensureChannel() val app = application as FieldwatchApp + ensureChannel(app.config.settings.language) wifi = WifiRadio( this, onObservation = { offer(it) }, @@ -256,6 +258,7 @@ class ScanService : LifecycleService() { visibleOnLive = app::wouldShowOnLive, arrivalsOnly = app.config.filter.arrivalsOnly, demoMode = settings.demoMode, + language = settings.language, ) } app.tak.publish( @@ -273,7 +276,10 @@ class ScanService : LifecycleService() { } private fun startAsForeground() { - val notification = buildNotification("Starting radios…") + val app = application as FieldwatchApp + val language = app.config.settings.language + ensureChannel(language) + val notification = buildNotification(translateAppText("Starting radios…", language)) if (Build.VERSION.SDK_INT >= 34) { startForeground( NOTIF_ID, @@ -296,13 +302,20 @@ class ScanService : LifecycleService() { val now = System.currentTimeMillis() if (now - lastNotifAt < 2_500L) return lastNotifAt = now - val stats = (application as FieldwatchApp).devices.stats.value - val text = "${stats.wifiNow} Wi-Fi · ${stats.bleNow} BLE · ${stats.namedNow} signatures" + val app = application as FieldwatchApp + val language = app.config.settings.language + ensureChannel(language) + val stats = app.devices.stats.value + val text = translateAppText( + "${stats.wifiNow} Wi-Fi · ${stats.bleNow} BLE · ${stats.namedNow} signatures", + language, + ) val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIF_ID, buildNotification(text)) } private fun buildNotification(text: String): Notification { + val language = (application as FieldwatchApp).config.settings.language val launch = PendingIntent.getActivity( this, 0, @@ -317,12 +330,12 @@ class ScanService : LifecycleService() { ) return NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(R.drawable.ic_stat_fieldwatch) - .setContentTitle("Fieldwatch scanning") + .setContentTitle(translateAppText("Fieldwatch scanning", language)) .setContentText(text) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(launch) - .addAction(0, "Stop", stop) + .addAction(0, translateAppText("Stop", language), stop) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .build() } @@ -373,15 +386,21 @@ class ScanService : LifecycleService() { return null } - private fun ensureChannel() { + private fun ensureChannel(language: app.fieldwatch.domain.AppLanguage) { if (Build.VERSION.SDK_INT < 26) return + if (channelLanguage == language) return val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.createNotificationChannel( - NotificationChannel(CHANNEL, "Scanning", NotificationManager.IMPORTANCE_LOW).apply { - description = "Passive Wi-Fi and Bluetooth scan status" + NotificationChannel( + CHANNEL, + translateAppText("Scanning", language), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = translateAppText("Passive Wi-Fi and Bluetooth scan status", language) setShowBadge(false) }, ) + channelLanguage = language } companion object { diff --git a/app/src/main/java/app/fieldwatch/ui/AppText.kt b/app/src/main/java/app/fieldwatch/ui/AppText.kt new file mode 100644 index 0000000..c04fd37 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/ui/AppText.kt @@ -0,0 +1,699 @@ +package app.fieldwatch.ui + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text as MaterialText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import app.fieldwatch.domain.AppLanguage + +val LocalAppLanguage = staticCompositionLocalOf { AppLanguage.ENGLISH } + +private val spanish = mapOf( + "Settings" to "Ajustes", + "Fieldwatch scanning" to "Fieldwatch está escaneando", + "Fieldwatch watchlist" to "Seguimiento de Fieldwatch", + "Watchlist alerts" to "Alertas de seguimiento", + "Passive Wi-Fi and Bluetooth scan status" to "Estado del escaneo pasivo de Wi-Fi y Bluetooth", + "Appearing signatures and devices on your watchlist. Beep is played separately." to "Firmas y dispositivos de la lista de seguimiento que aparecen. El pitido se reproduce por separado.", + "Fieldwatch fast scan needs Developer options" to "El escaneo rápido de Fieldwatch requiere las opciones de desarrollador", + "Scanning is not running." to "El escaneo no está en ejecución.", + "Location is off. Android will not deliver scan results." to "La ubicación está desactivada. Android no proporcionará resultados de escaneo.", + "Location permission is off." to "El permiso de ubicación está desactivado.", + "Wi-Fi and Bluetooth are off." to "Wi-Fi y Bluetooth están desactivados.", + "Scanning is starting." to "El escaneo está iniciándose.", + "Wi-Fi scan refused. BLE scan is parked. Check Location, then Location services, then Wi-Fi scanning and Bluetooth scanning." to "Se rechazó el escaneo Wi-Fi. El escaneo BLE está suspendido. Comprueba Ubicación, los servicios de ubicación y, después, el escaneo Wi-Fi y Bluetooth.", + "Wi-Fi scan refused. Check Location, then Location services, then Wi-Fi scanning." to "Se rechazó el escaneo Wi-Fi. Comprueba Ubicación, los servicios de ubicación y, después, el escaneo Wi-Fi.", + "Wi-Fi and Bluetooth scans are not running." to "Los escaneos Wi-Fi y Bluetooth no están en ejecución.", + "Wi-Fi scan refused. BLE is running." to "Se rechazó el escaneo Wi-Fi. BLE está en ejecución.", + "Wi-Fi is off. BLE is running." to "Wi-Fi está desactivado. BLE está en ejecución.", + "Bluetooth is off. Wi-Fi is running." to "Bluetooth está desactivado. Wi-Fi está en ejecución.", + "Stop" to "Detener", + "Starting radios…" to "Iniciando radios…", + "appeared" to "detectado", + "Appearance" to "Apariencia", + "Language" to "Idioma", + "Signature only (hide unmatched)" to "Solo firmas (ocultar sin coincidencia)", + "English" to "Inglés", + "Spanish" to "Español", + "Night mode" to "Modo nocturno", + "Keep screen on" to "Mantener pantalla encendida", + "Privacy mode" to "Modo de privacidad", + "Scanning" to "Escaneo", + "Watchlist" to "Lista de seguimiento", + "Location" to "Ubicación", + "Logging" to "Registro", + "Settings backup" to "Copia de seguridad de ajustes", + "Reports" to "Informes", + "Sits" to "Sesiones", + "Path and Debrief use RAM, not a saved sit." to "Trayectoria e Informe usan la memoria RAM, no una sesión guardada.", + "Sit report" to "Informe de sesión", + "Sit export" to "Exportación de sesión", + "Compare sits" to "Comparar sesiones", + "Catalog" to "Catálogo", + "Log export" to "Exportar registro", + "Identity" to "Identidad", + "Matching" to "Coincidencias", + "Color" to "Color", + "Rules" to "Reglas", + "Presets" to "Ajustes preestablecidos", + "New detections" to "Nuevas detecciones", + "Who stays" to "Qué radios permanecen", + "Signature classes" to "Clases de firmas", + "Selected signatures" to "Firmas seleccionadas", + "Fine filter" to "Filtro avanzado", + "Source" to "Origen", + "Fields" to "Campos", + "Preview" to "Vista previa", + "View" to "Vista", + "Sort" to "Ordenar", + "Brief hold" to "Retención breve", + "Title line" to "Línea de título", + "Subtitle line" to "Línea de subtítulo", + "Format" to "Formato", + "Kind" to "Tipo", + "When" to "Cuándo", + "Type" to "Tipo", + "Endian" to "Orden de bytes", + "Battery saver" to "Ahorro de batería", + "Balanced" to "Equilibrado", + "High performance" to "Alto rendimiento", + "Faster Wi-Fi AP scans" to "Escaneos Wi-Fi más rápidos", + "Developer options required" to "Se requieren las opciones de desarrollador", + "Open developer options" to "Abrir opciones de desarrollador", + "OK" to "Aceptar", + "Not now" to "Ahora no", + "Allow background usage" to "Permitir uso en segundo plano", + "Unrestricted battery" to "Batería sin restricciones", + "Open Android settings" to "Abrir ajustes de Android", + "Watchlist alerts" to "Alertas de seguimiento", + "Beep on watched signature" to "Pitido para firmas seguidas", + "Voice on watched signature" to "Voz para firmas seguidas", + "What to say" to "Qué decir", + "Test alert" to "Probar alerta", + "Jump to new watched detection" to "Ir a nuevas detecciones seguidas", + "System notification" to "Notificación del sistema", + "Tag detections with GPS" to "Etiquetar detecciones con GPS", + "Online place names and maps" to "Nombres de lugares y mapas en línea", + "TAK / CoT feed" to "Flujo TAK / CoT", + "Write detections to disk" to "Guardar detecciones en disco", + "Export signatures" to "Exportar firmas", + "Save signatures to SD card / storage…" to "Guardar firmas en tarjeta SD / almacenamiento…", + "Import signatures…" to "Importar firmas…", + "Update stock catalog from GitHub" to "Actualizar catálogo base desde GitHub", + "Restore default signatures & presets" to "Restaurar firmas y ajustes preestablecidos", + "Export settings" to "Exportar ajustes", + "Save settings to SD card / storage…" to "Guardar ajustes en tarjeta SD / almacenamiento…", + "Import settings…" to "Importar ajustes…", + "Show Live tour" to "Mostrar recorrido de Live", + "Diagnostics" to "Diagnóstico", + "Import settings?" to "¿Importar ajustes?", + "Import" to "Importar", + "Cancel" to "Cancelar", + "Restore defaults?" to "¿Restaurar valores predeterminados?", + "Restore" to "Restaurar", + "Destination" to "Destino", + "This phone" to "Este teléfono", + "LAN multicast" to "Multidifusión LAN", + "Custom" to "Personalizado", + "What to send" to "Qué enviar", + "Extra attention" to "Atención especial", + "Payload location" to "Ubicación de carga útil", + "Watchlist" to "Lista de seguimiento", + "All signatures" to "Todas las firmas", + "Disclaimer and license" to "Aviso legal y licencia", + "Disclaimer" to "Aviso legal", + "MIT License" to "Licencia MIT", + "This is a hobby project, provided as-is under the MIT License. Use at your own risk." to "Este es un proyecto personal, proporcionado tal cual bajo la Licencia MIT. Úsalo bajo tu propia responsabilidad.", + "Detections, pattern matches, “Moving with you” / “possible tail,” Debrief text, and AI Export are hypotheses — not identity, not a legal finding, and not a complete RF capture. Radios that are off, asleep, randomized, cellular-only, or hidden by the OS will not appear." to "Las detecciones, coincidencias de patrones, «Se mueve contigo» / «posible seguimiento», el texto del informe y la Exportación de IA son hipótesis: no identifican a nadie, no constituyen conclusiones jurídicas ni capturan por completo las señales de radiofrecuencia. No aparecerán las radios apagadas, inactivas, aleatorizadas, solo celulares u ocultas por el sistema operativo.", + "You are solely responsible for how you use this app and for following local law. To the maximum extent permitted by law, Off Grid Pete LLC is not liable for indirect, incidental, special, consequential, or punitive damages arising from its use." to "Eres el único responsable del uso de esta aplicación y del cumplimiento de las leyes locales. En la máxima medida permitida por la ley, Off Grid Pete LLC no será responsable de daños indirectos, incidentales, especiales, consecuentes o punitivos derivados de su uso.", + "GPS stamps are this phone at hear-time, not the other radio, unless a decode map advertises its own latitude/longitude (Remote ID Location). Sharing a Debrief, sit compare, AI Export (sit or one radio), radio-detail Share as text, or log can take that path off the device. A TAK/CoT feed, when you turn it on, sends markers onto the LAN; that is on the operator." to "Las marcas GPS corresponden a la ubicación de este teléfono cuando detectó la señal, no a la otra radio, salvo que un mapa de decodificación anuncie su propia latitud/longitud (ubicación Remote ID). Compartir un informe, comparar sesiones, una Exportación de IA (de sesión o de una radio), los detalles de una radio como texto o un registro puede sacar esos datos del dispositivo. Un flujo TAK/CoT, si lo activas, envía marcadores a la LAN; su uso es responsabilidad del operador.", + "By checking the box and continuing, you accept these terms and the MIT License." to "Al marcar la casilla y continuar, aceptas estas condiciones y la Licencia MIT.", + "I have read this and I agree" to "He leído esto y estoy de acuerdo", + "Continue" to "Continuar", + "Fieldwatch needs the radios" to "Fieldwatch necesita acceso a las radios", + "Location, nearby Wi-Fi, Bluetooth scan, and notifications let Fieldwatch passively watch advertised networks and BLE devices. Nothing is transmitted." to "La ubicación, el Wi-Fi cercano, el escaneo Bluetooth y las notificaciones permiten a Fieldwatch detectar de forma pasiva redes anunciadas y dispositivos BLE. No se transmite nada.", + "Grant permissions" to "Conceder permisos", + "Live logging is paused until this finishes. Scanning continues." to "El registro en Live está en pausa hasta que termine. El escaneo continúa.", + "Please wait…" to "Espera…", + "Could not export" to "No se pudo exportar", + "Log saved" to "Registro guardado", + "The file was written to the folder you picked. In the system picker, use the menu to choose the SD card if you want it off internal storage." to "El archivo se guardó en la carpeta seleccionada. En el selector del sistema, abre el menú para elegir la tarjeta SD si no quieres guardarlo en el almacenamiento interno.", + "Log cleared" to "Registro borrado", + "Rotated files were deleted. New detections will start a fresh log." to "Se eliminaron los archivos rotativos. Las nuevas detecciones iniciarán un registro nuevo.", + "Detail" to "Detalles", + "No radio selected." to "No hay ninguna radio seleccionada.", + "Display" to "Pantalla", + "Strongest signal" to "Señal más intensa", + "Strongest (avg 30s)" to "Señal más intensa (prom. 30 s)", + "Newest heard" to "Detectado más recientemente", + "Newest alert" to "Alerta más reciente", + "Newest arrival" to "Llegada más reciente", + "New at bottom" to "Nuevos al final", + "Name A–Z" to "Nombre A–Z", + "Signatures first" to "Firmas primero", + "Off — Stale after only" to "Desactivado — solo caducidad", + "Mark seen" to "Marcar como visto", + "Reset seen" to "Restablecer vistos", + "Start over" to "Empezar de nuevo", + "Got it" to "Entendido", + "Reading the log and re-matching the catalog…" to "Leyendo el registro y comparándolo de nuevo con el catálogo…", + "No signature families in this log. Randomized addresses and house-like names are skipped. A candidate needs a unique on-air ID on two or more radios." to "No hay familias de firmas en este registro. Se omiten las direcciones aleatorias y los nombres genéricos. Un candidato necesita un identificador único en el aire en dos o más radios.", + "Create signature" to "Crear firma", + "Back" to "Atrás", + "Save" to "Guardar", + "Map cleartext BLE bytes after this signature matches. Encrypted ads stay hex." to "Asigna etiquetas a los bytes BLE en claro cuando coincida esta firma. Los anuncios cifrados se mantienen en hexadecimal.", + "Manufacturer" to "Fabricante", + "Service data" to "Datos del servicio", + "Add field" to "Añadir campo", + "Remove decode map" to "Eliminar mapa de decodificación", + "Remove decode map?" to "¿Eliminar mapa de decodificación?", + "Removes every field and the Live code mark. Save after adding fields still keeps the map." to "Elimina todos los campos y la marca de código de Live. Guarda después de añadir campos para conservar el mapa.", + "Clears all fields on this signature. Live no longer shows the code mark. Raw advertisements stay. This cannot be undone except by adding fields again." to "Elimina todos los campos de esta firma. Live dejará de mostrar la marca de código. Los anuncios sin procesar se conservan. No se puede deshacer salvo que vuelvas a añadir los campos.", + "Remove" to "Eliminar", + "No matching radio on the air. Save anyway; detail will fill in when one is heard." to "No hay ninguna radio coincidente en el aire. Puedes guardar; los detalles aparecerán cuando se detecte una.", + "A matching radio is on the air, but this advertisement has no bytes for the source above." to "Hay una radio coincidente en el aire, pero este anuncio no contiene bytes para el origen indicado.", + "Nothing parsed. Check offset, length, and that byte 0 is after the company ID." to "No se pudo analizar nada. Comprueba el desplazamiento, la longitud y que el byte 0 esté después del identificador de empresa.", + "Live row" to "Fila de Live", + "More" to "Más", + "Hide extra" to "Ocultar adicionales", + "Only if…" to "Solo si…", + "Only if" to "Solo si", + "Named values…" to "Valores con nombre…", + "Named values" to "Valores con nombre", + "Strong" to "Intenso", + "Add value" to "Añadir valor", + "equals" to "es igual a", + "not equals" to "no es igual a", + "mask" to "máscara", + "none of bits" to "ningún bit", + "payload length" to "longitud de carga útil", + "little" to "little-endian", + "big" to "big-endian", + "Not on the air. This is the last detail we heard." to "No está en el aire. Estos son los últimos detalles recibidos.", + "Custom name" to "Nombre personalizado", + "Advertised" to "Anunciado", + "Advertised name" to "Nombre anunciado", + "Name" to "Nombre", + "No advertised name" to "Sin nombre anunciado", + "Saved" to "Guardado", + "Save name" to "Guardar nombre", + "Mine" to "Mío", + "While on, this radio stays listed and does not beep." to "Mientras esté activado, esta radio permanece en la lista y no emite pitidos.", + "Closer to 0 dBm is louder here, not a distance." to "Un valor más cercano a 0 dBm indica más intensidad, no distancia.", + "Decoded fields" to "Campos decodificados", + "Signal trend" to "Tendencia de señal", + "Presence (15 min)" to "Presencia (15 min)", + "Hunt" to "Búsqueda", + "Create signature from device" to "Crear firma desde dispositivo", + "Share as text" to "Compartir como texto", + "AI Export" to "Exportación de IA", + "Signature family" to "Familia de firmas", + "Notes" to "Notas", + "Observer notes" to "Notas del observador", + "No observer notes" to "Sin notas del observador", + "Save notes" to "Guardar notas", + "Pattern match, not identity. Not a safety finding." to "Coincidencia de patrón, no de identidad. No es una conclusión de seguridad.", + "What this looks like" to "Aspecto de esta detección", + "Tap to replace the whole filter. Long-press a chip to delete it. " to "Toca para reemplazar todo el filtro. Mantén pulsada una etiqueta para eliminarla.", + "Both" to "Ambos", + "Wi-Fi only" to "Solo Wi-Fi", + "BLE only" to "Solo BLE", + "Moving with you" to "Se mueve contigo", + "Watched only" to "Solo seguidos", + "Named radios only" to "Solo radios con nombre", + "Hide my radios" to "Ocultar mis radios", + "Hide Fast Pair account-key" to "Ocultar claves de cuenta Fast Pair", + "Show only" to "Mostrar solo", + "Hide these" to "Ocultar estos", + "Show only selected signatures" to "Mostrar solo las firmas seleccionadas", + "Hide selected signatures" to "Ocultar las firmas seleccionadas", + "Extra filter logic" to "Lógica de filtros adicionales", + "AND" to "Y", + "OR" to "O", + "Reset filter" to "Restablecer filtro", + "Reset filter?" to "¿Restablecer filtro?", + "Reset" to "Restablecer", + "Delete preset?" to "¿Eliminar ajuste preestablecido?", + "Delete" to "Eliminar", + "Class A–Z" to "Clase A–Z", + "Filters → Show only / Hide these. Class sits (Finder tags, Cameras, …) are those chips — Save current as… if you want a preset." to "Filtros → Mostrar solo / Ocultar estos. Las clases (etiquetas localizadoras, cámaras, …) son esas etiquetas. Usa Guardar actual como… para crear un ajuste preestablecido.", + "Match any rule (OR)" to "Coincidir con cualquier regla (O)", + "Cluster by OUI" to "Agrupar por OUI", + "Sequential MACs" to "Direcciones MAC secuenciales", + "Add rule" to "Añadir regla", + "Delete signature" to "Eliminar firma", + "Delete this signature?" to "¿Eliminar esta firma?", + "Wi-Fi" to "Wi-Fi", + "Matches hidden SSIDs" to "Coincide con SSID ocultos", + "Reset this hunt" to "Reiniciar esta búsqueda", + "Back to detail" to "Volver a los detalles", + "Beep" to "Pitido", + "Vibrate" to "Vibración", + "Show all" to "Mostrar todo", + "Collapse empty" to "Contraer vacíos", + "Clear named radios?" to "¿Borrar radios con nombre?", + "Clear" to "Borrar", + "Named radio" to "Radio con nombre", + "Alert" to "Alerta", + "End sit" to "Finalizar sesión", + "Start sit" to "Iniciar sesión", + "No saved sits." to "No hay sesiones guardadas.", + "Rename" to "Cambiar nombre", + "Delete all sits" to "Eliminar todas las sesiones", + "Debrief (text)" to "Informe (texto)", + "Debrief (PDF)" to "Informe (PDF)", + "Show unmatched rotating BLE" to "Mostrar BLE rotatorios sin coincidencia", + "Show all radios" to "Mostrar todas las radios", + "Second sit" to "Segunda sesión", + "Compare (text)" to "Comparar (texto)", + "Compare (PDF)" to "Comparar (PDF)", + "Signature candidates" to "Candidatas a firma", + "Reset / clear log" to "Restablecer / borrar registro", + "Clear the log?" to "¿Borrar el registro?", + "Clear log" to "Borrar registro", + "Start" to "Iniciar", + "Delete this sit?" to "¿Eliminar esta sesión?", + "Delete all sits?" to "¿Eliminar todas las sesiones?", + "Delete all" to "Eliminar todo", + "Share" to "Compartir", + "Save to SD card / storage…" to "Guardar en tarjeta SD / almacenamiento…", + "Copy" to "Copiar", + "Copied." to "Copiado.", + "Continue" to "Continuar", + "Hide these" to "Ocultar estos", + "Rename sit" to "Cambiar nombre de sesión", + "Observer notes" to "Notas del observador", + "Selected color" to "Color seleccionado", + "Hunt is BLE only. Wi-Fi access points update too slowly on stock Android to walk toward." to "La búsqueda solo funciona con BLE. En Android estándar, los puntos de acceso Wi-Fi se actualizan demasiado despacio para usarlos como guía de proximidad.", + "Disclaimer: passive Wi-Fi + BLE only. Nothing is transmitted." to "Aviso: solo Wi-Fi y BLE pasivos. No se transmite nada.", + "Pairing flood" to "Avalancha de emparejamientos", + "Name flood" to "Avalancha de nombres", + "Wi-Fi beacon flood" to "Avalancha de balizas Wi-Fi", + "Continue leaves them on Live for the rest of this sit. Hide these takes this burst, and later bursts in this sit, off Live. The sit and the log still keep them." to "Continuar las mantiene en Live durante el resto de esta sesión. Ocultar estos elementos quita esta ráfaga y las posteriores de Live durante la sesión. La sesión y el registro las conservan.", + "Continue leaves them on Live for about the next 15 minutes. Hide these takes this burst, and later bursts in that time, off Live. The sit and the log still keep them." to "Continuar las mantiene en Live durante unos 15 minutos. Ocultar estos elementos quita esta ráfaga y las posteriores de Live durante ese periodo. La sesión y el registro las conservan.", + "A repeated name, a mesh, an extender, or a guest network is not counted." to "No se cuentan los nombres repetidos, las redes malladas, los extensores ni las redes de invitados.", + "The advertisement does not name the tool." to "El anuncio no identifica la herramienta.", + "Filters → Named radios only hides everything else. Signature watches stay on Signatures." to "Filtros → Solo radios con nombre oculta las demás. Las firmas seguidas permanecen en Firmas.", + "Export the catalog (stock plus any you added or edited) to share with another Fieldwatch or as a backup." to "Exporta el catálogo (el contenido base y lo que hayas añadido o editado) para compartirlo con otro Fieldwatch o como copia de seguridad.", + "Import adds new rows and extra rules; it does not delete anything." to "La importación añade filas y reglas; no elimina nada.", + "Same id or the same match rules are skipped so a pack can be imported twice." to "Se omiten los mismos ID o reglas para poder importar un paquete dos veces.", + "Needs internet. Offline: Import signatures from a file." to "Requiere Internet. Sin conexión: importa firmas desde un archivo.", + "The rotating file is JSON lines (one hear per line)." to "El archivo rotativo usa líneas JSON (una detección por línea).", + "A matching radio is on the air, but this advertisement has no bytes for the source above." to "Hay una radio coincidente en el aire, pero este anuncio no contiene bytes para el origen indicado.", + "New at bottom" to "Nuevos al final", + "System notification" to "Notificación del sistema", + "Fast Pair account-key" to "clave de cuenta Fast Pair", + "Signature watches stay on Signatures." to "Las firmas seguidas permanecen en Firmas.", + "Restore defaults below still wipes customs." to "Restaurar valores predeterminados elimina las personalizaciones.", + "Background stays dark. Phone brightness is unchanged." to "El fondo sigue oscuro. El brillo del teléfono no cambia.", + "Off by default." to "Desactivado de forma predeterminada.", + "On by default." to "Activado de forma predeterminada.", + "YOU" to "TÚ", + "Start" to "Iniciar", + "Path start" to "Inicio", + "Now" to "Ahora", + "End" to "Fin", + "= pilot" to "= piloto", + "Opens a paste-ready prompt for a chat: decode this radio, look up OUI/company/UUIDs, and say what it most likely is. Same experimental disclaimer as Settings → AI Export. One device only — not identity." to "Abre una instrucción lista para pegar en un chat: decodifica esta radio, consulta el OUI, la empresa y los UUID, e indica qué podría ser. Incluye el mismo aviso experimental que Ajustes → Exportación de IA. Solo un dispositivo; no determina su identidad.", + "Signatures only (hide unmatched)" to "Solo firmas (ocultar las que no coinciden)", + "Independent chips. Extra attention (on): body-cam, glasses, recording wearables, pentest, public-safety APs." to "Etiquetas independientes. Atención especial (activada): cámaras corporales, gafas, dispositivos ponibles de grabación, pruebas de penetración y puntos de acceso de seguridad pública.", + "Independent chips. Extra attention (on): body-cam, glasses, recording wearables, pentest, public-safety APs. Payload location (on): advertised lat/lon from a decode map — required for stock Remote ID, which has no Extra attention mark. Watchlist (off): bookmarked signatures and named radios with Alert on. All signatures (off): every labeled radio — noisy in a plaza. Unmatched radios never go. A pin still needs coordinates: advertised payload, or GPS tagging with a live fix. Heard-here holds the loudest hear, not the last, and callsigns end in (here). Remote ID keeps one aircraft marker (UAS ID) plus a pilot pin when that location decoded." to "Las etiquetas son independientes. Atención especial (activada): cámaras corporales, gafas, dispositivos ponibles de grabación, pruebas de penetración y puntos de acceso de seguridad pública. Ubicación de carga útil (activada): latitud/longitud anunciada por un mapa de decodificación; es necesaria para Remote ID estándar, que no tiene marca de Atención especial. Lista de seguimiento (desactivada): firmas guardadas y radios con nombre cuya alerta está activada. Todas las firmas (desactivada): todas las radios etiquetadas; puede haber demasiado ruido en una plaza. Las radios sin coincidencia no se envían. Un marcador requiere coordenadas: las anunciadas en la carga útil o una posición GPS activa. «Detectado aquí» conserva la detección más intensa, no la última, y los indicativos terminan en (aquí). Remote ID mantiene un marcador de aeronave (ID UAS) y un marcador del piloto cuando se decodifica esa ubicación.", + "Selected color" to "Color seleccionado", + "Observer notes cleared" to "Se borraron las notas del observador", + "Observer notes saved" to "Se guardaron las notas del observador", + "Marked as yours. No beep while this is on. Still listed." to "Marcada como tuya. No emitirá pitidos mientras esté activado. Seguirá en la lista.", + "Back" to "Atrás", + "Edit" to "Editar", + "Watch" to "Seguir", + "New signature" to "Nueva firma", + "Delete rule" to "Eliminar regla", + "Delete field" to "Eliminar campo", + "Delete value" to "Eliminar valor", + "Live" to "Live", + "Filters" to "Filtros", + "Signatures" to "Firmas", + "Reports" to "Informes", + "Named radios" to "Radios con nombre", + "Radios" to "Radios", + "Log" to "Registro", + "Path" to "Trayectoria", + "Debrief" to "Informe", + "Classic radar" to "Radar clásico", + "Strength list" to "Lista por intensidad", + "Timeline" to "Cronología", + "Hybrid + sparklines" to "Híbrido + minigráficas", + "By class" to "Por clase", + "Finder tags" to "Etiquetas localizadoras", + "Retail beacons" to "Balizas comerciales", + "Signage" to "Señalización", + "Wearables" to "Dispositivos ponibles", + "Surveillance" to "Vigilancia", + "Drones" to "Drones", + "Pentest" to "Pruebas de penetración", + "Body-worn" to "Portado en el cuerpo", + "Public safety" to "Seguridad pública", + "Vehicle" to "Vehículo", + "Glasses" to "Gafas", + "Audio" to "Audio", + "Cameras" to "Cámaras", + "Thermostats" to "Termostatos", + "Access control" to "Control de acceso", + "Health" to "Salud", + "Home IoT" to "IoT doméstico", + "ISP / routers" to "Proveedor / enrutadores", + "Mesh" to "Red mallada", + "Phones / PCs" to "Teléfonos / PC", + "Other" to "Otro", + "Class" to "Clase", + "Signature" to "Firma", + "Class + signature" to "Clase + firma", + "Advertised name" to "Nombre anunciado", + "Name + type" to "Nombre + tipo", + "MAC address" to "Dirección MAC", + "None" to "Ninguno", + "Signature only (hide unmatched)" to "Solo firmas (ocultar sin coincidencia)", + "Show only already hides unmatched radios. Turn Show only (class or selected signatures) off to use this switch." to "Mostrar solo ya oculta las radios sin coincidencia. Desactiva Mostrar solo (clase o firmas seleccionadas) para usar este interruptor.", + "Only radios that match a bookmarked signature, or a Named radio with Alert on. " to "Solo radios que coinciden con una firma guardada o una radio con nombre y alerta activada.", + "Only radios you gave a custom name. Alert can still be off. Settings → Named radios. " to "Solo radios a las que asignaste un nombre personalizado. La alerta puede estar desactivada. Ajustes → Radios con nombre.", + "Radios marked Mine stay off Live. The sit and Debrief still have them. " to "Las radios marcadas como Mío no aparecen en Live. La sesión y el informe sí las incluyen.", + "Plaza noise: already-paired Fast Pair chips with no other signature. " to "Ruido de la plaza: etiquetas Fast Pair ya emparejadas sin ninguna otra firma.", + "Live only — signatures still label, log, and can beep. " to "Solo en Live: las firmas siguen etiquetando, registrando y pueden emitir pitidos.", + "AND/OR applies to name, OUI, RSSI, and class include — not to radios, Named radios only, Watched only, Hide my radios, Hide Fast Pair account-key, or hide lists." to "Y/O se aplica al nombre, OUI, RSSI y clases incluidas, pero no a radios, Solo radios con nombre, Solo seguidos, Ocultar mis radios, Ocultar claves de cuenta Fast Pair ni listas de ocultación.", + "Clears every switch and pick on this tab (radios, classes, selected signatures, RSSI, name/OUI). " to "Desactiva todos los interruptores y selecciones de esta pestaña (radios, clases, firmas seleccionadas, RSSI, nombre/OUI).", + "Stock colors are by class (red pentest, amber cameras/ALPR, purple phones/tags, cyan wearables, green mesh, orange audio/glasses, teal in-car/vehicle). Change any row." to "Los colores predeterminados dependen de la clase (rojo: pruebas de penetración; ámbar: cámaras/ALPR; morado: teléfonos/etiquetas; cian: dispositivos ponibles; verde: redes malladas; naranja: audio/gafas; turquesa: dispositivos de vehículos). Puedes cambiar cualquier fila.", + "Each rule has its own switch. Off keeps the rule but it does not match. " to "Cada regla tiene su propio interruptor. Si está desactivado, la regla se conserva pero no se aplica.", + "Optional. After this signature matches, map cleartext BLE bytes to labels. Encrypted payloads stay hex." to "Opcional. Cuando coincida esta firma, asigna etiquetas a los bytes BLE en claro. Las cargas cifradas se muestran en hexadecimal.", + "Hide this burst" to "Ocultar esta ráfaga", + "Display paused · radios still scanning and logging. Filters still apply when you run again. Tap Live to run the list again." to "Pantalla en pausa; las radios siguen escaneando y registrando. Los filtros se aplicarán al reanudar. Toca Live para volver a mostrar la lista.", + "Last 15 minutes · solid bars are on-air windows" to "Últimos 15 minutos · las barras sólidas indican periodos en el aire", + "Privacy mode is on. MAC tails in Debrief, sit compare, AI Export (sit or compare), and detail Share are **:**:**. GPS coordinates and a Remote ID UAS id in those reports are masked. The log file, sit export, and GPX / KML / WiGLE files still have full addresses and lat/lon." to "El modo de privacidad está activado. Las partes finales de las MAC en Informe, Comparar sesiones, Exportación de IA (sesión o comparación) y Compartir detalles aparecen como **:**:**. Las coordenadas GPS y el ID UAS de Remote ID se ocultan en esos informes. El archivo de registro, la exportación de sesión y los archivos GPX/KML/WiGLE mantienen las direcciones completas y latitud/longitud.", + "End sit to pick a saved one for Path and Debrief." to "Finaliza la sesión para elegir una guardada en Trayectoria e Informe.", + "Off (default): Debrief text/PDF lists skip unmatched RAND BLE. Counts still include them. Extra attention, named signatures, bookmarks, and payload pins stay. Sit export has every radio." to "Desactivado (predeterminado): los informes de texto/PDF omiten los BLE RAND sin coincidencia. Los recuentos sí los incluyen. Atención especial, firmas con nombre, elementos guardados y ubicaciones de carga útil se mantienen. La exportación de sesión incluye todas las radios.", + "Off (default): Debrief and Compare lead with counts. A radio is listed when it is Extra attention, has a custom name, is marked Mine, or is bookmarked. Compare also lists a decoded value that changed. On: the full rosters return. Show unmatched rotating BLE applies to those lists. The PDF draws the counts as bars. Sit export has every radio." to "Desactivado (predeterminado): Informe y Comparar comienzan con los recuentos. Se muestra una radio si tiene Atención especial, un nombre personalizado, está marcada como Mío o está guardada. Comparar también muestra los valores decodificados que cambiaron. Activado: vuelve a mostrarse la lista completa. Mostrar BLE rotatorios sin coincidencia se aplica a esas listas. El PDF representa los recuentos como barras. La exportación de sesión incluye todas las radios.", + "Paste-ready addendum: rates, RSSI bands, Extra attention and tracking IDs. Does not reprint Debrief inventories. One-radio AI Export is on detail." to "Anexo listo para pegar: tasas, rangos RSSI, Atención especial e identificadores de seguimiento. No repite los inventarios del informe. La exportación de IA de una radio está en sus detalles.", + "Paste-ready addendum: overlap, exclusive Extra attention / Named radios, what another sit would shrink. Does not reprint the compare lists. Sit report AI Export stays this window only." to "Anexo listo para pegar: coincidencias, Atención especial / Radios con nombre exclusivas y qué reduciría otra sesión. No repite las listas comparadas. La exportación de IA del informe de sesión solo incluye este intervalo.", + "Debrief and AI Export use this window until you end it. The Live list is unchanged." to "Informe y Exportación de IA usarán este intervalo hasta que lo finalices. La lista de Live no cambia.", + "Off by default. Red-on-black field display so chips, text, and signal marks " to "Desactivado de forma predeterminada. Pantalla de campo roja sobre negro para que las etiquetas, el texto y las marcas de señal ", + "On by default. Stops the display from sleeping while Fieldwatch is open so BLE is not parked when the phone blanks. Scanning still runs in the notification if you leave the app. Turn it off when you pocket the phone." to "Activado de forma predeterminada. Evita que la pantalla se apague mientras Fieldwatch está abierto, para que BLE no se suspenda al apagarse el teléfono. El escaneo continúa desde la notificación si sales de la aplicación. Desactívalo cuando guardes el teléfono.", + "Hides the last three octets of every MAC on Live, radar, timeline, detail, Hunt, Named radios, and watchlist cards as **:**:** so the screen and sit reports do not show full addresses. GPS last-fix and Debrief / AI Export / detail Share coordinates become “masked”; street names are omitted from those sit reports. The first three octets (OUI / vendor prefix) stay. Off by default. The map on Reports → Path still loads when Online place names and maps is on. Logs, matching, filters, Hunt math, Moving with you, and saved signatures still use the real MAC and GPS. A TAK / CoT feed, if you turned it on, is paused while this is on so full MACs and coordinates are not sent onto the LAN. Turn this off when you need the full address or coordinates on screen." to "Oculta los tres últimos octetos de cada MAC en Live, Radar, Cronología, Detalles, Búsqueda, Radios con nombre y tarjetas de seguimiento como **:**:** para que la pantalla y los informes no muestren direcciones completas. La última posición GPS y las coordenadas en Informe / Exportación de IA / Compartir detalles aparecen como «ocultas»; se omiten los nombres de calles. Los tres primeros octetos (OUI / prefijo del fabricante) permanecen visibles. Desactivado de forma predeterminada. El mapa de Informes → Trayectoria sigue cargándose si Nombres de lugares y mapas en línea está activado. Los registros, coincidencias, filtros, cálculos de Búsqueda, Se mueve contigo y firmas guardadas siguen usando las MAC y el GPS reales. Si activaste un flujo TAK / CoT, se pausa para no enviar MAC completas ni coordenadas a la LAN. Desactívalo para ver direcciones o coordenadas completas.", + "Wi-Fi is a batch radio: the phone grabs every AP at once, then must wait. High performance asks about every 30s — that is the fastest cadence that stays under the OS limit of four scans per two minutes. BLE still streams in between." to "Wi-Fi funciona por lotes: el teléfono detecta todos los puntos de acceso a la vez y después debe esperar. Alto rendimiento solicita un escaneo cada 30 s, la frecuencia máxima dentro del límite del sistema de cuatro escaneos cada dos minutos. BLE sigue transmitiendo entre escaneos.", + "Mirrors Android Allow background usage. Tap to open Fieldwatch’s Battery page and " to "Equivale a Permitir uso en segundo plano de Android. Toca para abrir la página de batería de Fieldwatch y ", + "Mirrors Android Unrestricted (not Optimized). Some phones (Samsung among them) do not " to "Equivale a Sin restricciones (no optimizada) en Android. Algunos teléfonos (incluidos los Samsung) no ", + "Custom names for one MAC. Alert is optional. Filters → Named radios only shows them on Live. Signature watches stay on Signatures." to "Nombres personalizados para una MAC. La alerta es opcional. Filtros → Solo radios con nombre las muestra en Live. Las firmas seguidas permanecen en Firmas.", + "For signature watches: Class is the Live glyph bucket (finder tags, audio, …). Signature is the catalog row (Apple AirTags, Axon, …). Class + signature (default) says both. A named radio with Alert on always says its custom name, even if it has no class. Test alert plays the signature mix you have on." to "Para firmas seguidas: Clase es la categoría del icono de Live (etiquetas localizadoras, audio, …). Firma es la fila del catálogo (Apple AirTags, Axon, …). Clase + firma (predeterminado) dice ambos. Una radio con nombre y alerta activada siempre pronuncia su nombre personalizado, aunque no tenga clase. Probar alerta reproduce la combinación de firmas seleccionada.", + "When a new watched signature or device appears, Live scrolls to that row so you can see the flash. Works with beep, voice, or both. Weak hits sit at the bottom of a strength-ranked list. Turn this off if you do not want the list to move." to "Cuando aparece una nueva firma o dispositivo seguido, Live se desplaza hasta esa fila para mostrar el destello. Funciona con pitido, voz o ambos. Las detecciones débiles quedan al final de una lista ordenada por intensidad. Desactívalo si no quieres que la lista se mueva.", + "Optional. Posts a silent shade card when a watched radio appears. Off by default — the beep and flash are enough, and skipping the card keeps the scan loop lighter." to "Opcional. Muestra una notificación silenciosa cuando aparece una radio seguida. Desactivada de forma predeterminada: el pitido y el destello suelen bastar, y omitir la notificación reduce la carga del escaneo.", + "Copyright (c) 2026 Off Grid Pete LLC. All rights reserved." to "Copyright (c) 2026 Off Grid Pete LLC. Todos los derechos reservados.", + "MAC prefix" to "Prefijo MAC", + "Name contains" to "El nombre contiene", + "Name glob" to "Patrón del nombre", + "Service UUID" to "UUID del servicio", + "Service data" to "Datos del servicio", + "Manufacturer ID" to "ID del fabricante", + "Manufacturer data" to "Datos del fabricante", + "Radio kind" to "Tipo de radio", + "Hidden SSID" to "SSID oculto", + "Vendor IE OUI" to "OUI de IE del fabricante", + "OUI" to "OUI", + "Unmatched" to "Sin coincidencia", + "Wi-Fi access point" to "Punto de acceso Wi-Fi", + "BLE advertiser" to "Emisor BLE", + "More display options below" to "Más opciones de pantalla abajo", + "Observer notes" to "Notas del observador", + "Alerted this session" to "Generó una alerta en esta sesión", + "Strongest" to "Más intensa", + "Average" to "Promedio", + "Instant" to "Instantánea", + "Copied." to "Copiado.", + "Paste this into an issue. It has no network names, no addresses, and no GPS coordinates." to "Pega esto en una incidencia. No contiene nombres de redes, direcciones ni coordenadas GPS.", + "Optional. Byte 0 is the first byte after the company ID. Empty = any record." to "Opcional. El byte 0 es el primer byte después del identificador de empresa. Vacío = cualquier registro.", + "Required. 16-bit (FEAA) or full UUID." to "Obligatorio. UUID de 16 bits (FEAA) o UUID completo.", + "Show this value next to the signature name. Strong values use a heavier chip." to "Muestra este valor junto al nombre de la firma. Los valores intensos usan una etiqueta más marcada.", + "Extra attention" to "Atención especial", + "Pattern match, not identity. Not a safety finding." to "Coincidencia de patrón, no de identidad. No es una conclusión de seguridad.", + "The double pip on media volume when a bookmarked signature or device first appears, or returns after leaving. Sitting detections do not beep again. Independent of Voice — use beep, voice, or both. Raise media volume if you hear nothing, then tap Test alert." to "Emite dos tonos al volumen multimedia cuando aparece por primera vez una firma o un dispositivo guardado, o cuando vuelve tras desaparecer. Las detecciones que permanecen no vuelven a emitir el tono. Es independiente de la voz: puedes usar el pitido, la voz o ambos. Si no oyes nada, sube el volumen multimedia y toca Probar alerta.", + "On by default. Speaks on the same media volume as the pip. Independent of Beep: with Beep on, voice follows the pip; with Beep off, voice only. Not Hunt. If a phrase is already being spoken, a second hit is skipped. Phones with no text-to-speech still beep if Beep is on." to "Activada de forma predeterminada. Habla al mismo volumen multimedia que el pitido. Es independiente del pitido: si está activado, la voz lo sigue; si está desactivado, solo se oye la voz. No afecta a Búsqueda. Si ya se está diciendo una frase, se omite la siguiente detección. Los teléfonos sin síntesis de voz siguen emitiendo el pitido si está activado.", + "On by default. Master switch for bookmarked signatures and devices. Off: no beep, vibration, flash, jump, or shade card. Bookmarking still works — you just will not be told when that radio appears." to "Activada de forma predeterminada. Control principal de las firmas y dispositivos guardados. Desactivada: no hay pitido, vibración, destello, salto ni notificación. Puedes seguir guardando elementos, pero no recibirás avisos cuando aparezca esa radio.", + "On by default. Requests live GPS/network updates and stamps each hear (Live detail, Moving with you, " to "Activada de forma predeterminada. Solicita actualizaciones GPS/de red en directo y registra cada detección (detalles de Live, Se mueve contigo, ", + "On by default. When the phone has internet, Debrief / AI Export reverse-geocode GPS stamps " to "Activada de forma predeterminada. Cuando el teléfono tiene Internet, Informe / Exportación de IA convierten las coordenadas GPS en nombres de lugares ", + "Off by default. Sends Cursor-on-Target UDP markers to ATAK, WinTAK, or iTAK. " to "Desactivada de forma predeterminada. Envía marcadores Cursor-on-Target por UDP a ATAK, WinTAK o iTAK. ", + "Privacy mode is on — the feed is paused so full MACs and coordinates are not sent. Turn Privacy mode off to publish." to "El modo de privacidad está activado: el flujo está en pausa para no enviar direcciones MAC completas ni coordenadas. Desactiva el modo de privacidad para publicar.", + "The rotating file is JSON lines (one hear per line). Reports → Log → Format writes CSV, JSON lines, GPX, KML, or WiGLE when you Share or Save." to "El archivo rotativo usa líneas JSON (una detección por línea). Informes → Registro → Formato genera CSV, líneas JSON, GPX, KML o WiGLE al compartir o guardar.", + "Export the catalog (stock plus any you added or edited) to share with another Fieldwatch or as a backup. Import adds new rows and extra rules; it does not delete anything. Same id or the same match rules are skipped so a pack can be imported twice. Update stock catalog from GitHub replaces stock rows (including Extra attention) from the v2 pack on the repo; bookmarks, Settings, and signatures you added stay. Needs internet. Offline: Import signatures from a file. Restore defaults below still wipes customs." to "Exporta el catálogo (el contenido base y lo que hayas añadido o editado) para compartirlo con otro Fieldwatch o como copia de seguridad. La importación añade filas y reglas; no elimina nada. Se omiten los mismos ID o reglas para poder importar un paquete dos veces. Actualizar el catálogo base desde GitHub reemplaza las filas base (incluida Atención especial) con el paquete v2 del repositorio; conserva los elementos guardados, los ajustes y las firmas añadidas. Requiere Internet. Sin conexión: importa firmas desde un archivo. Restaurar valores predeterminados sí elimina las personalizaciones.", + "Chrome overlay on Live: Tune is Display (Radar, list, By class), Pause, Filters, Signatures, Reports, Settings. First-run after the license; this button shows it again." to "Superposición de Live: Ajustar abre Pantalla (Radar, lista, Por clase), Pausa, Filtros, Firmas, Informes y Ajustes. Aparece la primera vez después de la licencia; este botón la vuelve a mostrar.", + "Phone model, Android version, Faster Wi-Fi AP scans, Allow background usage, Unrestricted battery, and whether the radios and Location are actually scanning. Copy pastes that into an issue. No network names and no GPS coordinates." to "Modelo del teléfono, versión de Android, escaneos Wi-Fi más rápidos, uso en segundo plano permitido, batería sin restricciones y estado real del escaneo de radios y ubicación. Copiar permite pegar esta información en una incidencia. No incluye nombres de redes ni coordenadas GPS.", + "Settings switches, the current filter, filter presets, named radios, and signature watches. " to "Interruptores de ajustes, filtro actual, ajustes preestablecidos, radios con nombre y firmas seguidas. ", + "Rewrites the catalog (stock rows, class colors, Decode fields), stock bookmarks, " to "Reescribe el catálogo (filas base, colores de clase y campos de decodificación), los elementos guardados de fábrica, ", + "North up. The line is this phone. The black dot is the start. The blue dot is you, at the last point. A MAC or signature alert is one class icon. A decoded latitude and longitude uses the last position that radio sent. A count is several in one spot. Thick green is a stay. Time ticks along the path." to "Norte arriba. La línea representa este teléfono. El punto negro es el inicio. El azul indica tu última posición. Una alerta MAC o de firma aparece como un icono de clase. Las coordenadas decodificadas muestran la última posición enviada por esa radio. Un número indica varias radios en el mismo lugar. El trazo verde grueso indica una permanencia. Las marcas de tiempo aparecen a lo largo de la trayectoria.", + "Tap a count for the radios there. Tap a single icon for that one radio. Tap again to close. Tap a row in that list, or a row below, to open that radio." to "Toca un número para ver las radios de ese lugar. Toca un icono para abrir esa radio. Tócalo de nuevo para cerrar. Toca una fila de la lista o de abajo para abrir la radio.", + "No MAC or signature alerts with a GPS stamp on this path." to "No hay alertas de MAC o firma con posición GPS en esta trayectoria.", + "Unmatched radios in the log that share a unique ID — not every unknown. You review; nothing is added until you Save." to "Radios sin coincidencia del registro que comparten un identificador único; no se muestran todas las desconocidas. Revísalas: no se añade nada hasta que guardes.", + "This deletes all rotated CSV/JSON files on the phone. It cannot be undone. Live scanning will start a new empty log." to "Esto elimina todos los archivos CSV/JSON rotativos del teléfono. No se puede deshacer. El escaneo de Live iniciará un registro vacío.", + "Removes the saved sit from this phone. The log is unchanged." to "Elimina la sesión guardada de este teléfono. El registro no cambia.", + "Removes saved sits from this phone. An open sit is not deleted. The log is unchanged." to "Elimina las sesiones guardadas de este teléfono. No elimina una sesión abierta. El registro no cambia.", + "No named radios. Set a custom name on detail, or bookmark a radio (top-right) to watch it." to "No hay radios con nombre. Asigna un nombre personalizado en los detalles o guarda una radio (arriba a la derecha) para seguirla.", + "One MAC each. Custom name shows on Live. Mine marks it as yours: no beep while on, still listed. Observer notes show on detail and reports. Alert is optional (pip / voice / flash). Filters → Named radios only hides everything else. Signature watches stay on Signatures." to "Una dirección MAC por radio. El nombre personalizado aparece en Live. Mío la marca como tuya: no emite pitidos mientras está activada, pero sigue en la lista. Las notas del observador aparecen en los detalles y los informes. La alerta es opcional (pitido, voz o destello). Filtros → Solo radios con nombre oculta las demás. Las firmas seguidas permanecen en Firmas.", + "A sit is a named window of radios heard here. The selection below drives Path, Debrief, and Compare’s this-sit side: open sit, a selected saved sit, or last 15 minutes if you never start one." to "Una sesión es un intervalo con nombre que agrupa las radios detectadas aquí. La selección determina Trayectoria, Informe y el lado de esta sesión en Comparar: la sesión abierta, una sesión guardada seleccionada o los últimos 15 minutos si no inicias ninguna.", + "Save a second sit to compare. Start sit, then End sit. Last 15 minutes can be this sit." to "Guarda una segunda sesión para comparar. Inicia una sesión y luego finalízala. También puedes usar los últimos 15 minutos como esta sesión.", + "Same report, two formats. Presence only — only in this sit, only in the second, in both. Kind + MAC. Extra attention and Named radios are marked. Not a radio fix." to "El mismo informe en dos formatos. Solo presencia: únicamente en esta sesión, únicamente en la segunda o en ambas. Tipo + MAC. Se marcan Atención especial y Radios con nombre. No indica la posición de la radio.", + "A sit is a named window of radios heard here. " to "Una sesión es un intervalo con nombre que agrupa las radios detectadas aquí. ", + "Passive Wi-Fi + BLE only. " to "Solo Wi-Fi y BLE pasivos. ", + "Wi-Fi waiting on OS" to "Wi-Fi esperando al sistema", + "Wi-Fi scanning" to "Escaneando Wi-Fi", + "Needs Android 11+ so Fieldwatch can read whether the OS is still throttling scans. This phone cannot confirm that, so the switch stays off." to "Se necesita Android 11 o posterior para que Fieldwatch pueda comprobar si el sistema limita los escaneos. Este teléfono no puede confirmarlo, así que el interruptor permanece desactivado.", + "On. Fieldwatch asks for a new AP list about every 8 seconds. Uses more battery and heat. If the OS starts refusing scans, it backs off." to "Activado. Fieldwatch solicita una nueva lista de puntos de acceso cada 8 segundos aproximadamente. Aumenta el consumo de batería y el calor. Si el sistema empieza a rechazar escaneos, reduce la frecuencia.", + "Saved on, but not in effect — Android Wi-Fi scan throttling is still on. Turn that off in Developer options, then return here." to "Está guardado como activado, pero no se aplica: la limitación de escaneos Wi-Fi de Android sigue activa. Desactívala en Opciones de desarrollador y vuelve aquí.", + "Stock Android allows about four AP scans per two minutes. Faster scans only run after you turn off Wi-Fi scan throttling in Developer options. Fieldwatch checks that OS switch before turning this on, and cannot change it for you." to "Android estándar permite unos cuatro escaneos de puntos de acceso cada dos minutos. Los escaneos rápidos solo funcionan si desactivas la limitación de escaneos Wi-Fi en Opciones de desarrollador. Fieldwatch comprueba ese ajuste antes de activarlos, pero no puede cambiarlo.", + "Path start" to "Inicio", + "YOU" to "TÚ", + "Start" to "Iniciar", + "Now" to "Ahora", + "End" to "Fin", + "= pilot" to "= piloto", + "Observer notes cleared" to "Se borraron las notas del observador", + "Observer notes saved" to "Se guardaron las notas del observador", + "Marked as yours. No beep while this is on. Still listed." to "Marcada como tuya. No emitirá pitidos mientras esté activado. Seguirá en la lista.", + "Opens a paste-ready prompt for a chat: decode this radio, look up OUI/company/UUIDs, and say what it most likely is. Same experimental disclaimer as Settings → AI Export. One device only — not identity." to "Abre una instrucción lista para pegar en un chat: decodifica esta radio, consulta el OUI, la empresa y los UUID, e indica qué podría ser. Incluye el mismo aviso experimental que Ajustes → Exportación de IA. Solo un dispositivo; no determina su identidad.", + "Signatures only (hide unmatched)" to "Solo firmas (ocultar las que no coinciden)", + "Back" to "Atrás", + "Edit" to "Editar", + "Watch" to "Seguir", + "New signature" to "Nueva firma", + "Delete rule" to "Eliminar regla", + "Delete field" to "Eliminar campo", + "Delete value" to "Eliminar valor", + "Off by default. Red-on-black field display so chips, text, and signal marks do not dump green or blue into a dark sit. Background stays dark. Phone brightness is unchanged." to "Desactivado de forma predeterminada. La pantalla de campo roja sobre negro evita que las etiquetas, el texto y las marcas de señal proyecten tonos verdes o azules en una sesión oscura. El fondo sigue oscuro y el brillo del teléfono no cambia.", + "Mirrors Android Allow background usage. Tap to open Fieldwatch’s Battery page and use that switch. Fieldwatch updates when you return. Off: the OS can kill the scan as soon as you leave. Not Keep screen on." to "Equivale a Permitir uso en segundo plano de Android. Toca para abrir la página de batería de Fieldwatch y usar ese interruptor. Fieldwatch actualizará el estado al volver. Si está desactivado, el sistema puede detener el escaneo al salir. No depende de Mantener pantalla encendida.", + "Mirrors Android Unrestricted (not Optimized). Some phones (Samsung among them) do not open onto that choice. If you only see Allow background usage, tap that row to click through and select Unrestricted. Fieldwatch updates when you return." to "Equivale a Sin restricciones (no optimizada) en Android. Algunos teléfonos, incluidos los Samsung, no abren directamente esa opción. Si solo aparece Permitir uso en segundo plano, toca esa fila para acceder y seleccionar Sin restricciones. Fieldwatch actualizará el estado al volver.", + "The next screen is Fieldwatch’s Battery page. Use the Allow background usage switch. Fieldwatch will match that setting when you return." to "La siguiente pantalla es la página de batería de Fieldwatch. Usa el interruptor Permitir uso en segundo plano. Fieldwatch actualizará el estado al volver.", + "Some phones (Samsung among them) do not open onto Unrestricted / Optimized / Restricted. If you only see Allow background usage, tap that row (the words, not the blue switch) to click through, then select Unrestricted. Fieldwatch will match that when you return." to "Algunos teléfonos, incluidos los Samsung, no abren directamente las opciones Sin restricciones / Optimizada / Restringida. Si solo aparece Permitir uso en segundo plano, toca esa fila (el texto, no el interruptor azul) para acceder y selecciona Sin restricciones. Fieldwatch actualizará el estado al volver.", + "On by default. Requests live GPS/network updates and stamps each hear (Live detail, Moving with you, Debrief, and lat/lon on new log rows). Last-known-only is ignored if older than 30 s. That is your GPS at hear-time, not an independent fix on the other radio. Use high-accuracy Location or the path stays 0. Turn off if you do not want operator coordinates on logs. Heard-here TAK pins also need this; advertised payload coordinates (Remote ID) do not." to "Activado de forma predeterminada. Solicita actualizaciones GPS/de red en directo y añade la posición a cada detección (detalles de Live, Se mueve contigo, Informe y latitud/longitud en las filas nuevas del registro). Se ignora la última ubicación conocida si tiene más de 30 s. Es la posición GPS del teléfono al detectar la señal, no una ubicación independiente de la otra radio. Usa la ubicación de alta precisión o la trayectoria permanecerá en 0. Desactívalo si no quieres incluir las coordenadas del operador en los registros. Los marcadores TAK «detectado aquí» también lo necesitan; las coordenadas anunciadas en la carga útil (Remote ID) no.", + "On by default. When the phone has internet, Debrief / AI Export reverse-geocode GPS stamps to street/city, and Reports → Path loads OpenStreetMap tiles under the trace. No Fieldwatch cloud, no API key. Offline or no geocoder: Debrief uses coordinates only and Path stays the current north-up trace — no error dialog. Turn off to keep streets and map tiles out of reports and Path. Debrief, Sit export, Log export, and Reset / clear log are on the Reports tab." to "Activado de forma predeterminada. Cuando el teléfono tiene Internet, Informe / Exportación de IA convierten las marcas GPS en calles o ciudades, e Informes → Trayectoria carga mapas de OpenStreetMap bajo el recorrido. No se usa la nube de Fieldwatch ni una clave API. Sin conexión o sin servicio de geocodificación, el informe solo usa coordenadas y Trayectoria conserva el recorrido orientado al norte, sin mostrar errores. Desactívalo para excluir calles y mapas de los informes y la trayectoria. Informe, Exportación de sesión, Exportación del registro y Restablecer / borrar registro están en la pestaña Informes.", + "Logging is on. New detections are appended to the rotating file." to "El registro está activado. Las nuevas detecciones se añaden al archivo rotativo.", + "Logging is off. Scanning still runs; nothing new is written until you turn this back on." to "El registro está desactivado. El escaneo continúa, pero no se guardará nada hasta que lo vuelvas a activar.", + "Settings switches, the current filter, filter presets, named radios, and signature watches. Not the catalog — that is Export signatures. Not logs or GPS. Import replaces those on this phone; the catalog stays. If the file would turn the TAK / CoT feed on or change its destination, Fieldwatch asks first. Use this after a factory reset or on a new phone." to "Interruptores de ajustes, filtro actual, ajustes preestablecidos, radios con nombre y firmas seguidas. No incluye el catálogo: usa Exportar firmas. Tampoco incluye registros ni GPS. La importación reemplaza esos ajustes en este teléfono y conserva el catálogo. Si el archivo activa el flujo TAK / CoT o cambia su destino, Fieldwatch lo confirmará antes. Úsalo después de restablecer el teléfono o en un dispositivo nuevo.", + "Rewrites the catalog (stock rows, class colors, Decode fields), stock bookmarks, stock filter chips, and default Settings switches. Custom signatures and chips you saved are wiped. Export signatures and Export settings first if you want a backup. This cannot be undone." to "Reemplaza el catálogo (filas base, colores de clase y campos de decodificación), elementos guardados de fábrica, etiquetas de filtro base e interruptores predeterminados. Elimina las firmas personalizadas y las etiquetas guardadas. Exporta las firmas y los ajustes antes si quieres conservar una copia. No se puede deshacer.", + "Passive Wi-Fi + BLE only. Stock Android cannot promiscuously capture Wi-Fi stations; access points and BLE advertisers are what the radios expose." to "Solo Wi-Fi y BLE pasivos. Android estándar no puede capturar estaciones Wi-Fi en modo promiscuo; las radios solo exponen puntos de acceso y emisores BLE.", + "This phone’s IPv4 · none" to "IPv4 de este teléfono · ninguna", + "Turn on Settings → Tag detections with GPS, then walk or drive. Only loud BLE advertisers that stay with you along the path. Wi-Fi access points stay off — a loud AP you drive past paints your path. The switch starts a BLE follow test (clears Signatures only / Show only / Named radios only / Watched only). Or tap the Moving with you preset at the top." to "Activa Ajustes → Etiquetar detecciones con GPS y luego camina o conduce. Solo se siguen emisores BLE intensos que permanecen contigo durante el recorrido. Se excluyen los puntos de acceso Wi-Fi: uno intenso por el que pases alteraría la trayectoria. El interruptor inicia una prueba de seguimiento BLE (desactiva Solo firmas / Mostrar solo / Solo radios con nombre / Solo seguidos). También puedes tocar el ajuste preestablecido Se mueve contigo de arriba.", + "Off by default. Sends Cursor-on-Target UDP markers to ATAK, WinTAK, or iTAK." to "Desactivado de forma predeterminada. Envía marcadores Cursor-on-Target por UDP a ATAK, WinTAK o iTAK.", + "is ATAK CIV on this handset." to "actúa como ATAK CIV en este dispositivo.", + "Custom is a unicast IPv4 or hostname. UDP only — a TAK server’s TCP 8087 is not this feed." to "Personalizado admite una dirección IPv4 unicast o un nombre de host. Solo UDP: el puerto TCP 8087 de un servidor TAK no corresponde a este flujo.", + "Heard-here pins sit at this phone’s GPS at the loudest hear (closest approach) and are labeled (here)." to "Los marcadores «detectado aquí» usan la posición GPS de este teléfono en la detección más intensa (acercamiento máximo) y se etiquetan como (aquí).", + "Walking away does not drag the pin; a louder hear moves it." to "Alejarse no mueve el marcador; una detección más intensa sí.", + "Keep-alives refresh the same lat/lon every ~10 s so ATAK does not drop it." to "Los mensajes periódicos actualizan la misma latitud/longitud cada ~10 s para que ATAK no lo descarte.", + "Advertised lat/lon (stock Remote ID) sit on the aircraft; the same Remote ID keeps one marker that moves (UAS ID, not the rotating BLE MAC)." to "Las coordenadas anunciadas (Remote ID estándar) corresponden a la aeronave; el mismo Remote ID mantiene un único marcador móvil (ID UAS, no la MAC BLE rotativa).", + "A decoded pilot location is a second pin." to "La ubicación decodificada del piloto aparece como un segundo marcador.", + "Gone radios are dropped on ATAK instead of sitting 120 s." to "Las radios desaparecidas se eliminan de ATAK en lugar de permanecer 120 s.", + "Tap a marker in ATAK for remarks (name, MAC, RSSI, signatures)." to "Toca un marcador en ATAK para ver sus notas (nombre, MAC, RSSI y firmas).", + "Not direction-finding. Not a Remote ID plugin. Privacy mode pauses the feed." to "No determina la dirección ni sustituye a un complemento Remote ID. El modo de privacidad pausa el flujo.", + "New detections only" to "Solo nuevas detecciones", + "New detections only · learning" to "Solo nuevas detecciones · aprendiendo", +) + +private val spanishFragments = listOf( + "This phone (" to "Este teléfono (", + "LAN multicast is " to "La multidifusión LAN es ", + "This phone’s IPv4 · " to "IPv4 de este teléfono · ", +) + +private val dynamicSpanish = listOf( + Regex("^Scan intensity\\s+·\\s+(.+)$") to "Intensidad de escaneo · \$1", + Regex("^Rotate at (\\d+) KB$") to "Rotar en \$1 KB", + Regex("^Stale after (\\d+)s$") to "Caducar después de \$1 s", + Regex("^Hold (\\d+)s after last packet$") to "Mantener durante \$1 s tras el último paquete", + Regex("^Named radios \\((\\d+)\\)$") to "Radios con nombre (\$1)", + Regex("^Clear all (\\d+) named radios$") to "Borrar las \$1 radios con nombre", + Regex("^Remove (\\d+) named radios\\. Signature watches stay\\.$") to "Se eliminarán \$1 radios con nombre. Se conservarán las firmas seguidas.", + Regex("^(.+) dBm$") to "\$1 dBm", + Regex("^Signatures \\((\\d+)\\)$") to "Firmas (\$1)", + Regex("^Saved as (.+)$") to "Guardado como \$1", + Regex("^Strongest · avg (\\d+)s$") to "Más intensa · prom. \$1 s", + Regex("^Hold (\\d+)s$") to "Mantener \$1 s", + Regex("^Wi-Fi next (\\d+)s$") to "Próximo escaneo Wi-Fi en \$1 s", + Regex("^(\\d+) Wi-Fi · (\\d+) BLE · (\\d+) signatures$") to "\$1 Wi-Fi · \$2 BLE · \$3 firmas", + Regex("^Minimum RSSI\\s+(-?\\d+) dBm$") to "RSSI mínimo \$1 dBm", + Regex("^Restored Settings, the current filter, and (\\d+) (preset|presets)\\. (\\d+) (named radio|named radios), (\\d+) (signature watch|signature watches)\\.$") to + "Se restauraron los ajustes y el filtro actual, además de \$1 ajustes preestablecidos, \$3 radios con nombre y \$5 firmas seguidas.", + Regex("^This file turns the TAK / CoT feed on and sets the destination to (.+)\\. The feed is plain UDP\\.$") to + "Este archivo activa el flujo TAK / CoT y establece el destino en \$1. El flujo usa UDP sin cifrar.", + Regex("^This file turns the TAK / CoT feed on\\. It sends plain UDP to (.+)\\.$") to + "Este archivo activa el flujo TAK / CoT. Envía UDP sin cifrar a \$1.", + Regex("^This file changes the TAK / CoT destination to (.+)\\. The feed is plain UDP and stays on\\.$") to + "Este archivo cambia el destino TAK / CoT a \$1. El flujo sigue activado y usa UDP sin cifrar.", + Regex("^This file changes the TAK / CoT destination to (.+)\\. The feed stays off until you turn it on\\.$") to + "Este archivo cambia el destino TAK / CoT a \$1. El flujo permanece desactivado hasta que lo actives.", + Regex("^Fieldwatch (.+) · Catalog (\\d+)$") to "Fieldwatch \$1 · Catálogo \$2", + Regex("^(\\d+) lines this session · ([0-9.]+) KB on disk\\.?") to "\$1 líneas en esta sesión · \$2 KB en disco.", +) + +private val dynamicSpanishFragments = listOf( + Regex("^GPS path so far (\\d+) m\\.") to "Recorrido GPS hasta ahora: \$1 m.", + Regex("^GPS path (\\d+) m\\. Signatures only, class Show only, Named radios only, or Watched only is also on, so only those radios can co-travel\\.") to + "Recorrido GPS: \$1 m. También está activado Solo firmas, Mostrar solo por clase, Solo radios con nombre o Solo seguidos, así que solo esas radios pueden acompañarte.", + Regex("^GPS path (\\d+) m\\. Loud BLE heard along that path at a fairly steady level — not ones that only appear when you arrive\\.") to + "Recorrido GPS: \$1 m. Se detectó una señal BLE intensa y relativamente estable durante el trayecto, no solo al llegar.", +) + +internal fun translateAppText(text: String, language: AppLanguage): String { + if (language != AppLanguage.SPANISH) return text + spanish[text]?.let { return it } + for ((pattern, replacement) in dynamicSpanish) { + if (pattern.matches(text)) return pattern.replace(text, replacement) + } + for ((pattern, replacement) in dynamicSpanishFragments) { + if (pattern.containsMatchIn(text)) return pattern.replace(text, replacement) + } + if (text.startsWith("Feed status · ")) { + return text + .replaceFirst("Feed status · ", "Estado del flujo · ") + .replace("paused (Privacy mode)", "en pausa (modo de privacidad)") + .replace("no send yet this session", "sin envíos en esta sesión") + .replace("error:", "error:") + .replace("on the feed ", "en el flujo ") + .replace("sent ", "enviados ") + .replace(" gone", " desaparecidos") + } + Regex("^(.+) · (\\d+) rules · (\\d+) live · (OR|AND)$").matchEntire(text)?.let { match -> + val kind = spanish[match.groupValues[1]] ?: match.groupValues[1] + val conjunction = if (match.groupValues[4] == "OR") "O" else "Y" + return "$kind · ${match.groupValues[2]} reglas · ${match.groupValues[3]} activas · $conjunction" + } + if (text.endsWith(" · OR") || text.endsWith(" · AND")) { + val translatedKind = text.substringBefore(" · ").let { + spanish[it] ?: it + } + return Regex("^.+ · (\\d+) rules · (\\d+) live · (OR|AND)$") + .matchEntire(text) + ?.let { match -> + "$translatedKind · ${match.groupValues[1]} reglas · ${match.groupValues[2]} activas · " + + if (match.groupValues[3] == "OR") "O" else "Y" + } + ?: text + } + val withMappedFragments = spanish.entries + .asSequence() + .filter { it.key.length >= 30 && text.contains(it.key) } + .sortedByDescending { it.key.length } + .fold(text) { translated, (english, spanish) -> translated.replace(english, spanish) } + return spanishFragments.fold(withMappedFragments) { translated, (english, spanish) -> + translated.replace(english, spanish) + } +} + +@Composable +fun localizeAppText(text: String): String = translateAppText(text, LocalAppLanguage.current) + +@Composable +fun AppText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + MaterialText( + text = translateAppText(text, LocalAppLanguage.current), + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style, + ) +} + +@Composable +fun AppText( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, +) { + MaterialText( + text = text, + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style, + ) +} diff --git a/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt b/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt index ff90b04..8b335fe 100644 --- a/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt +++ b/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt @@ -83,7 +83,7 @@ fun RadioClassBadge( Box(contentAlignment = Alignment.Center) { Icon( ClassGlyphs.of(classKind), - contentDescription = classKind?.label() ?: "Unmatched", + contentDescription = localizeAppText(classKind?.label() ?: "Unmatched"), modifier = Modifier.size(if (compact) 14.dp else 16.dp), tint = accent, ) @@ -99,7 +99,9 @@ fun RadioKindMark( ) { Icon( if (kind == RadioKind.WIFI) Icons.Outlined.Wifi else Icons.Outlined.Bluetooth, - contentDescription = if (kind == RadioKind.WIFI) "Wi-Fi access point" else "BLE advertiser", + contentDescription = localizeAppText( + if (kind == RadioKind.WIFI) "Wi-Fi access point" else "BLE advertiser", + ), modifier = modifier.size(size), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt b/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt index 7f6af76..0a9fb92 100644 --- a/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt +++ b/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt @@ -74,7 +74,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import app.fieldwatch.ui.component.FieldwatchSwitch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -130,16 +130,18 @@ import app.fieldwatch.ui.theme.FieldwatchTheme @Composable fun FieldwatchRoot(vm: FieldwatchViewModel, onRequestPermissions: () -> Unit) { val state by vm.ui.collectAsStateWithLifecycle() - FieldwatchTheme( - darkTheme = true, - nightMode = state.settings.nightMode, - ) { - if (!state.settings.disclaimerOk()) { - DisclaimerGate(onAccept = vm::acceptDisclaimer) - } else if (!state.permissionsOk) { - PermissionGate(onRequestPermissions) - } else { - FieldwatchShell(state, vm) + CompositionLocalProvider(LocalAppLanguage provides state.settings.language) { + FieldwatchTheme( + darkTheme = true, + nightMode = state.settings.nightMode, + ) { + if (!state.settings.disclaimerOk()) { + DisclaimerGate(onAccept = vm::acceptDisclaimer) + } else if (!state.permissionsOk) { + PermissionGate(onRequestPermissions) + } else { + FieldwatchShell(state, vm) + } } } } @@ -691,7 +693,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { title = { Text("Detail") }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, localizeAppText("Back")) } }, ) @@ -981,7 +983,7 @@ private fun ViewPicker( ) { Icon( Icons.Outlined.ExpandMore, - contentDescription = "More display options below", + contentDescription = localizeAppText("More display options below"), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) diff --git a/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt b/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt index 5a509e5..a901f81 100644 --- a/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt +++ b/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember diff --git a/app/src/main/java/app/fieldwatch/ui/NestedTabChrome.kt b/app/src/main/java/app/fieldwatch/ui/NestedTabChrome.kt index 02cd12d..890a311 100644 --- a/app/src/main/java/app/fieldwatch/ui/NestedTabChrome.kt +++ b/app/src/main/java/app/fieldwatch/ui/NestedTabChrome.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp diff --git a/app/src/main/java/app/fieldwatch/ui/component/FieldwatchDropdownField.kt b/app/src/main/java/app/fieldwatch/ui/component/FieldwatchDropdownField.kt index 6e4d5e7..7669c61 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/FieldwatchDropdownField.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/FieldwatchDropdownField.kt @@ -10,7 +10,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier diff --git a/app/src/main/java/app/fieldwatch/ui/component/FieldwatchOutlinedField.kt b/app/src/main/java/app/fieldwatch/ui/component/FieldwatchOutlinedField.kt index 8ba873c..9630688 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/FieldwatchOutlinedField.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/FieldwatchOutlinedField.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier diff --git a/app/src/main/java/app/fieldwatch/ui/component/SectionCard.kt b/app/src/main/java/app/fieldwatch/ui/component/SectionCard.kt index 641a42c..fdbd09b 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/SectionCard.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/SectionCard.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight diff --git a/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt b/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt index f5a761a..652fcab 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,6 +58,7 @@ import app.fieldwatch.domain.SignatureClass import app.fieldwatch.domain.SitPathPlot import app.fieldwatch.ui.ClassGlyphs import app.fieldwatch.ui.RadioClassBadge +import app.fieldwatch.ui.localizeAppText import app.fieldwatch.ui.theme.Cyan import app.fieldwatch.ui.theme.LocalNightMode import app.fieldwatch.ui.theme.PhosphorActive @@ -85,6 +86,9 @@ fun SitPathCanvas( val surface = MaterialTheme.colorScheme.surface val outline = MaterialTheme.colorScheme.outline val measurer = rememberTextMeasurer() + val startLabel = localizeAppText("Path start") + val nowLabel = localizeAppText("Now") + val endLabelText = localizeAppText("End") val labelStyle = TextStyle(fontSize = 10.sp, color = muted) var boxSize by remember { mutableStateOf(IntSize.Zero) } var selectedId by remember { mutableStateOf(null) } @@ -198,9 +202,9 @@ fun SitPathCanvas( val start = lay.path.first() val end = lay.path.last() drawStartDot(start.x, start.y) - val startT = measurer.measure("Start", labelStyle) + val startT = measurer.measure(startLabel, labelStyle) drawText(startT, topLeft = Offset((start.x + 8f).coerceAtMost(size.width - startT.size.width), start.y - 6f)) - val endLabel = if (model.live) "Now" else "End" + val endLabel = if (model.live) nowLabel else endLabelText val endT = measurer.measure(endLabel, labelStyle) drawText( endT, diff --git a/app/src/main/java/app/fieldwatch/ui/component/StickyHeight.kt b/app/src/main/java/app/fieldwatch/ui/component/StickyHeight.kt index 0c4cbef..e9ed08a 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/StickyHeight.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/StickyHeight.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf diff --git a/app/src/main/java/app/fieldwatch/ui/component/Widgets.kt b/app/src/main/java/app/fieldwatch/ui/component/Widgets.kt index ba6ac4a..3c33d1e 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/Widgets.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/Widgets.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText diff --git a/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt index a9af91c..94c43b5 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -67,7 +67,7 @@ fun CandidatesScreen( title = "Signature candidates", navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, app.fieldwatch.ui.localizeAppText("Back")) } }, ) diff --git a/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt index 43eeb69..f73be60 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -326,7 +326,9 @@ private fun FieldCard( modifier = Modifier.weight(1f), ) TypeMenu(field.type, Modifier.width(112.dp)) { onChange(field.copy(type = it)) } - IconButton(onClick = onDelete) { Icon(Icons.Outlined.Delete, "Delete field") } + IconButton(onClick = onDelete) { + Icon(Icons.Outlined.Delete, app.fieldwatch.ui.localizeAppText("Delete field")) + } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { CompactField( @@ -562,7 +564,7 @@ private fun NamedValuesBlock( publish(nextRows, emphasis, notes) } }, - ) { Icon(Icons.Outlined.Delete, "Delete value") } + ) { Icon(Icons.Outlined.Delete, app.fieldwatch.ui.localizeAppText("Delete value")) } } if (field.live && raw.isNotBlank()) { Row( diff --git a/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt index c474015..0517f84 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt @@ -36,7 +36,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -100,6 +100,7 @@ fun DeviceDetailScreen( ?: rssiColor(device.rssi)) .nightIf(LocalNightMode.current) val facts = device.facts + val appLanguage = app.fieldwatch.ui.LocalAppLanguage.current val familyHint by vm.familyHint.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -114,7 +115,9 @@ fun DeviceDetailScreen( Text(MacUtil.redactMacIn(title, device.mac, demoMode), maxLines = 1) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, app.fieldwatch.ui.localizeAppText("Back")) + } }, actions = { IconButton(onClick = { vm.toggleWatchDevice(device) }) { @@ -212,7 +215,9 @@ fun DeviceDetailScreen( nameDraft = draftLabel lastSaved = draftLabel scope.launch { - snackbarHostState.showSnackbar("Saved as $draftLabel") + snackbarHostState.showSnackbar( + app.fieldwatch.ui.translateAppText("Saved as $draftLabel", appLanguage), + ) } }, modifier = Modifier.fillMaxWidth(), @@ -260,7 +265,10 @@ fun DeviceDetailScreen( editingNotes = false scope.launch { snackbarHostState.showSnackbar( - if (draftNotes.isBlank()) "Observer notes cleared" else "Observer notes saved", + app.fieldwatch.ui.translateAppText( + if (draftNotes.isBlank()) "Observer notes cleared" else "Observer notes saved", + appLanguage, + ), ) } }, diff --git a/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt index c6d3beb..cc56bcc 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt @@ -28,7 +28,7 @@ import androidx.compose.material3.Scaffold import app.fieldwatch.ui.component.FieldwatchSlider import androidx.compose.material3.Surface import app.fieldwatch.ui.component.FieldwatchSwitch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider diff --git a/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt index 5bcc05e..982384c 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt @@ -44,7 +44,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import app.fieldwatch.ui.component.FieldwatchSwitch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.runtime.Composable @@ -144,7 +144,7 @@ fun FleetsScreen( topBar = { NestedTopBar("Signatures (${state.fleets.size})") }, floatingActionButton = { FloatingActionButton(onClick = vm::beginNewFleet) { - Icon(Icons.Outlined.Add, "New signature") + Icon(Icons.Outlined.Add, app.fieldwatch.ui.localizeAppText("New signature")) } }, ) { pad -> @@ -545,7 +545,7 @@ private fun ColorPicker(selected: Int, onSelect: (Int) -> Unit) { if (on) { Icon( Icons.Outlined.Check, - contentDescription = "Selected color", + contentDescription = app.fieldwatch.ui.localizeAppText("Selected color"), tint = if (fill.luminance() > 0.45f) { Color(0xFF12171C) } else { @@ -592,7 +592,9 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: } } } - IconButton(onClick = onDelete) { Icon(Icons.Outlined.Delete, "Delete rule") } + IconButton(onClick = onDelete) { + Icon(Icons.Outlined.Delete, app.fieldwatch.ui.localizeAppText("Delete rule")) + } } Column( Modifier.padding(top = 12.dp, start = 8.dp), diff --git a/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt index 3c2106b..761f0a3 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.material3.MaterialTheme import app.fieldwatch.ui.component.FieldwatchActionButton import androidx.compose.material3.Scaffold import app.fieldwatch.ui.component.FieldwatchSwitch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -116,7 +116,7 @@ fun HuntScreen( }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, app.fieldwatch.ui.localizeAppText("Back")) } }, ) @@ -295,6 +295,7 @@ private fun HuntNeedle( val youColor = MaterialTheme.colorScheme.onSurface val ringIdle = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) val measurer = rememberTextMeasurer() + val youLabel = app.fieldwatch.ui.localizeAppText("YOU") val youStyle = androidx.compose.ui.text.TextStyle( color = youColor, fontSize = 11.sp, @@ -344,7 +345,7 @@ private fun HuntNeedle( HuntCue.GONE -> pulse(0.88f, 0.28f, 2.2f) } drawCircle(color.copy(alpha = 0.95f), radius = 5.5f, center = c) - val you = measurer.measure("YOU", youStyle) + val you = measurer.measure(youLabel, youStyle) drawText( you, topLeft = Offset(c.x - you.size.width / 2f, c.y + 10f), diff --git a/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt b/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt index 2a96fbb..08370a1 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt @@ -40,7 +40,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import app.fieldwatch.ui.component.FieldwatchOutlinedField import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import app.fieldwatch.domain.Sit import androidx.compose.runtime.Composable @@ -811,6 +811,7 @@ private fun RadarView( val labelColor = MaterialTheme.colorScheme.onSurfaceVariant val youColor = MaterialTheme.colorScheme.primary val measurer = rememberTextMeasurer() + val youLabel = app.fieldwatch.ui.localizeAppText("YOU") val onAir = devices.count { !it.gone } val ringStyle = TextStyle( color = labelColor, @@ -959,7 +960,7 @@ private fun RadarView( drawCircle(youColor, radius = 7f, center = c) drawCircle(youColor.copy(alpha = 0.2f), radius = 16f, center = c) - val you = measurer.measure("YOU", ringStyle.copy(color = youColor, fontWeight = FontWeight.Bold)) + val you = measurer.measure(youLabel, ringStyle.copy(color = youColor, fontWeight = FontWeight.Bold)) drawText(you, topLeft = Offset(c.x - you.size.width / 2f, c.y + 12f)) } @@ -1377,7 +1378,7 @@ private fun FleetNameChips( ) { Icon( Icons.AutoMirrored.Outlined.Notes, - contentDescription = "Observer notes", + contentDescription = app.fieldwatch.ui.localizeAppText("Observer notes"), modifier = Modifier .padding(horizontal = 5.dp, vertical = 1.dp) .size(11.dp), @@ -1393,7 +1394,7 @@ private fun FleetNameChips( ) { Icon( Icons.Outlined.Notifications, - contentDescription = "Alerted this session", + contentDescription = app.fieldwatch.ui.localizeAppText("Alerted this session"), modifier = Modifier .padding(horizontal = 5.dp, vertical = 1.dp) .size(11.dp), diff --git a/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt index b0c91b0..dbc0a22 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt @@ -30,7 +30,7 @@ import app.fieldwatch.ui.component.FieldwatchOutlinedField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import app.fieldwatch.ui.component.FieldwatchSwitch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -77,7 +77,7 @@ fun RadioBookmarksScreen( title = "Named radios (${radios.size})", navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, app.fieldwatch.ui.localizeAppText("Back")) } }, ) @@ -230,10 +230,10 @@ private fun BookmarkCard( ) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { IconButton(onClick = onRename, modifier = Modifier.size(36.dp)) { - Icon(Icons.Outlined.Edit, "Edit", modifier = Modifier.size(18.dp)) + Icon(Icons.Outlined.Edit, app.fieldwatch.ui.localizeAppText("Edit"), modifier = Modifier.size(18.dp)) } IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) { - Icon(Icons.Outlined.Delete, "Remove", modifier = Modifier.size(18.dp)) + Icon(Icons.Outlined.Delete, app.fieldwatch.ui.localizeAppText("Remove"), modifier = Modifier.size(18.dp)) } } } diff --git a/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt index 4a57c1e..6d71ae5 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt @@ -34,7 +34,7 @@ import androidx.compose.material3.RadioButton import app.fieldwatch.ui.component.FieldwatchActionButton import androidx.compose.material3.Scaffold import app.fieldwatch.ui.component.FieldwatchOutlinedField -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/app/fieldwatch/ui/screen/ScanStatusScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/ScanStatusScreen.kt index f5b0758..db3a8a7 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/ScanStatusScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/ScanStatusScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -70,7 +70,7 @@ fun ScanStatusScreen( title = "Diagnostics", navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, app.fieldwatch.ui.localizeAppText("Back")) } }, ) diff --git a/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt index cc2a013..13de88c 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt @@ -39,7 +39,7 @@ import androidx.compose.material3.Scaffold import app.fieldwatch.ui.component.FieldwatchSlider import androidx.compose.material3.Surface import app.fieldwatch.ui.component.FieldwatchSwitch -import androidx.compose.material3.Text +import app.fieldwatch.ui.AppText as Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -62,6 +62,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import app.fieldwatch.domain.AlertVoiceWhat +import app.fieldwatch.domain.AppLanguage import app.fieldwatch.domain.AppSettings import app.fieldwatch.domain.ScanIntensity import app.fieldwatch.domain.TakDefaults @@ -118,6 +119,19 @@ fun SettingsScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { SectionCard("Appearance") { + Text("Language", style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FieldwatchFilterChip( + selected = settings.language == AppLanguage.ENGLISH, + onClick = { vm.updateSettings { it.copy(language = AppLanguage.ENGLISH) } }, + label = { Text("English") }, + ) + FieldwatchFilterChip( + selected = settings.language == AppLanguage.SPANISH, + onClick = { vm.updateSettings { it.copy(language = AppLanguage.SPANISH) } }, + label = { Text("Spanish") }, + ) + } Row(verticalAlignment = Alignment.CenterVertically) { Text("Night mode", Modifier.weight(1f)) FieldwatchSwitch(settings.nightMode, { on -> vm.updateSettings { it.copy(nightMode = on) } }) @@ -755,7 +769,7 @@ private fun SocialChip( ) { Icon( painter = painterResource(icon), - contentDescription = label, + contentDescription = app.fieldwatch.ui.localizeAppText(label), tint = tint, modifier = Modifier.size(14.dp), ) diff --git a/app/src/test/java/app/fieldwatch/data/ConfigStoreDefaultsTest.kt b/app/src/test/java/app/fieldwatch/data/ConfigStoreDefaultsTest.kt new file mode 100644 index 0000000..067b5aa --- /dev/null +++ b/app/src/test/java/app/fieldwatch/data/ConfigStoreDefaultsTest.kt @@ -0,0 +1,40 @@ +package app.fieldwatch.data + +import app.fieldwatch.domain.AppLanguage +import app.fieldwatch.domain.AppSettings +import app.fieldwatch.domain.PersistedConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConfigStoreDefaultsTest { + @Test + fun restoringDefaultsRetainsLanguageAndDisclaimerButResetsOtherSettings() { + val current = PersistedConfig( + settings = AppSettings( + language = AppLanguage.SPANISH, + disclaimerAccepted = true, + disclaimerRev = 3, + nightMode = true, + keepScreenOn = false, + ), + ) + val defaults = PersistedConfig( + settings = AppSettings( + disclaimerAccepted = false, + disclaimerRev = 0, + nightMode = false, + keepScreenOn = true, + ), + ) + + val restored = restoreConfigDefaults(current, defaults) + + assertEquals(AppLanguage.SPANISH, restored.settings.language) + assertTrue(restored.settings.disclaimerAccepted) + assertEquals(3, restored.settings.disclaimerRev) + assertFalse(restored.settings.nightMode) + assertTrue(restored.settings.keepScreenOn) + } +} diff --git a/app/src/test/java/app/fieldwatch/domain/SettingsExchangeTest.kt b/app/src/test/java/app/fieldwatch/domain/SettingsExchangeTest.kt index 81c0c17..e4af733 100644 --- a/app/src/test/java/app/fieldwatch/domain/SettingsExchangeTest.kt +++ b/app/src/test/java/app/fieldwatch/domain/SettingsExchangeTest.kt @@ -15,6 +15,7 @@ class SettingsExchangeTest { assertTrue(stock.darkTheme) assertTrue(stock.keepScreenOn) assertTrue(stock.alertBeep) + assertEquals(AppLanguage.ENGLISH, stock.language) assertEquals(ViewMode.BY_CLASS, stock.viewMode) assertTrue(stock.showRssiBar) assertTrue(stock.showFleetName) @@ -58,6 +59,7 @@ class SettingsExchangeTest { keepScreenOn = false, takEnabled = true, takHost = "192.168.0.9", + language = AppLanguage.SPANISH, ), filter = FilterState(showWifi = false, showBle = true), presets = stockPresets + FilterPreset( @@ -92,6 +94,7 @@ class SettingsExchangeTest { assertFalse(json.contains("\"fleets\"")) val pack = SettingsExchange.parse(json) assertEquals(SettingsPack.FORMAT, pack.format) + assertEquals(AppLanguage.SPANISH, pack.settings.language) assertEquals(1, pack.watchlist.count { it.deviceKey == "BLE:C3:A6:A9:11:22:33" }) assertEquals("in the bag", pack.watchlist.single { it.deviceKey != null }.observerNotes) assertTrue(pack.watchlist.single { it.deviceKey != null }.mine) @@ -135,6 +138,7 @@ class SettingsExchangeTest { assertTrue(next.settings.nightMode) assertTrue(next.settings.darkTheme) assertTrue(next.settings.demoMode) + assertEquals(AppLanguage.SPANISH, next.settings.language) assertEquals("192.168.0.9", next.settings.takHost) assertTrue(next.settings.disclaimerAccepted) assertEquals(DISCLAIMER_REV, next.settings.disclaimerRev) @@ -149,6 +153,17 @@ class SettingsExchangeTest { assertEquals(setOf("BLE:AA:BB:CC:DD:EE:FF"), next.arrivalKnownKeys) } + @Test + fun olderSettingsWithoutLanguageDefaultToEnglish() { + val old = SettingsExchange.json.decodeFromString( + AppSettings.serializer(), + """{"nightMode":true,"keepScreenOn":false}""", + ) + assertEquals(AppLanguage.ENGLISH, old.language) + assertTrue(old.nightMode) + assertFalse(old.keepScreenOn) + } + @Test fun applyTwiceIsIdempotent() { val local = localConfig() diff --git a/app/src/test/java/app/fieldwatch/ui/AppTextTest.kt b/app/src/test/java/app/fieldwatch/ui/AppTextTest.kt new file mode 100644 index 0000000..ecb9d30 --- /dev/null +++ b/app/src/test/java/app/fieldwatch/ui/AppTextTest.kt @@ -0,0 +1,16 @@ +package app.fieldwatch.ui + +import app.fieldwatch.domain.AppLanguage +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppTextTest { + @Test + fun englishRemainsUnchangedAndSpanishTranslatesUiAndDynamicLabels() { + assertEquals("Settings", translateAppText("Settings", AppLanguage.ENGLISH)) + assertEquals("Ajustes", translateAppText("Settings", AppLanguage.SPANISH)) + assertEquals("Idioma", translateAppText("Language", AppLanguage.SPANISH)) + assertEquals("Rotar en 2048 KB", translateAppText("Rotate at 2048 KB", AppLanguage.SPANISH)) + assertEquals("Radios con nombre (12)", translateAppText("Named radios (12)", AppLanguage.SPANISH)) + } +}