diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7620c99..f7468e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ () + private const val ENGINE_LOG_MAX_LINES = 64 + private const val ENGINE_LOG_MAX_BYTES = 8 * 1024 + private const val CRASH_FILE = "pending_crash.txt" + private const val CRASH_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000 + + @Synchronized + fun appendEngineLog(level: Int, message: String) { + val line = "[${"DIWE".getOrElse(level) { 'X' }}] $message" + engineLog.addLast(line) + while (engineLog.size > ENGINE_LOG_MAX_LINES) engineLog.removeFirst() + // Drop the oldest while over the byte cap. + var bytes = engineLog.sumOf { it.length } + while (bytes > ENGINE_LOG_MAX_BYTES && engineLog.size > 1) { + bytes -= engineLog.removeFirst().length + } + } + + @Synchronized + private fun snapshotEngineLog(): String = engineLog.joinToString("\n") + /** Call from Application/Activity startup. Sets up the SDK only if already opted in. */ fun init(context: Context) { appContext = context.applicationContext @@ -88,6 +110,75 @@ object AnalyticsService { } } + // ── Crash reporting (RFC 0009) ───────────────────────────────────────────── + // JVM-level capture only in v1: a native SIGSEGV inside libdasher.so is not seen + // by Thread.setDefaultUncaughtExceptionHandler; a signal shim is a follow-up. + + /** Install the uncaught-exception handler. Call once from Application.onCreate. */ + fun installCrashHandler(context: Context) { + appContext = context.applicationContext + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + writeCrashFile(context, thread.name, throwable) + } catch (_: Throwable) { /* never throw in a crash handler */ } + previous?.uncaughtException(thread, throwable) + } + } + + /** + * On launch: if a pending crash file exists, send it (only if opted in — RFC 0009) + * and delete it. Crash files older than 7 days are discarded regardless. + */ + fun flushPendingCrash(context: Context) { + val file = java.io.File(context.filesDir, CRASH_FILE) + if (!file.exists()) return + val age = System.currentTimeMillis() - file.lastModified() + if (age > CRASH_MAX_AGE_MS) { file.delete(); return } + if (optedIn(context)) { + try { + val text = file.readText() + // Minimal envelope: lines "key=value", stack/engine tail after blank line. + val props = mutableMapOf() + val (header, body) = text.split("\n\n", limit = 2) + .let { it.first() to (it.getOrNull(1) ?: "") } + header.split('\n').forEach { ln -> + val idx = ln.indexOf('=') + if (idx > 0) props[ln.substring(0, idx)] = ln.substring(idx + 1) + } + if (body.isNotBlank()) props["stack_trace"] = body + capture("crash", props) + } catch (_: Throwable) { } + } + file.delete() + } + + private fun writeCrashFile(context: Context, threadName: String, t: Throwable) { + val sw = java.io.StringWriter() + t.printStackTrace(java.io.PrintWriter(sw)) + val stack = scrub(sw.toString()).take(16 * 1024) + val engineTail = scrub(snapshotEngineLog()).take(8 * 1024) + val header = buildString { + append("exception_type=").append(t::class.java.name).append('\n') + append("thread=").append(threadName).append('\n') + append("app_version=").append(appVersion()).append('\n') + append("os_version=Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})\n") + } + // stack_trace carries the JVM stack + the engine log tail (separated) so a + // maintainer can reconstruct what DasherCore was doing when the process died. + val body = if (engineTail.isNotBlank()) "$stack\n--- engine log ---\n$engineTail" else stack + java.io.File(context.filesDir, CRASH_FILE).writeText("$header\n\n$body") + } + + /** Scrub home-directory path segments and emails; respect RFC 0001's no-PII promise. */ + private fun scrub(s: String): String { + var out = s + out = Regex("""(/Users/|/home/)([^/\\]+)""").replace(out) { "${it.groupValues[1]}" } + out = Regex("""C:\\Users\\([^\\]+)""").replace(out, "C:\\Users\\") + out = Regex("""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""").replace(out, "") + return out + } + private fun anonId(context: Context): String = prefs(context).getString(KEY_ANON_ID, null) ?: UUID.randomUUID().toString().also { prefs(context).edit().putString(KEY_ANON_ID, it).apply() diff --git a/app/src/main/java/at/dasher/android/DasherApp.kt b/app/src/main/java/at/dasher/android/DasherApp.kt new file mode 100644 index 0000000..4e8dbcf --- /dev/null +++ b/app/src/main/java/at/dasher/android/DasherApp.kt @@ -0,0 +1,25 @@ +package at.dasher.android + +import android.app.Application +import android.content.Context + +/** + * Process entry point. Installs the crash handler + flushes any pending crash + * (RFC 0009) and initialises analytics (RFC 0001) as early as possible, before + * any Activity runs. The uncaught-exception handler writes a crash file even + * before the user has opted in; flushPendingCrash only transmits if they have. + */ +class DasherApp : Application() { + override fun onCreate() { + super.onCreate() + AnalyticsService.installCrashHandler(this) + AnalyticsService.flushPendingCrash(this) + AnalyticsService.init(this) + } + + // RFC 0003: wrap the base context so the Compose UI (strings.xml) follows the + // user-chosen locale on every API level, independent of the engine's own locale. + override fun attachBaseContext(base: Context) { + super.attachBaseContext(LocaleHelper.wrap(base)) + } +} diff --git a/app/src/main/java/at/dasher/android/DasherEngine.kt b/app/src/main/java/at/dasher/android/DasherEngine.kt index a8f7fc7..fd0bace 100644 --- a/app/src/main/java/at/dasher/android/DasherEngine.kt +++ b/app/src/main/java/at/dasher/android/DasherEngine.kt @@ -364,6 +364,9 @@ class DasherEngine( 1 -> Log.i("DasherCore", text) else -> Log.d("DasherCore", text) } + // Also feed the crash ring buffer (RFC 0009): info+ kept so a crash + // report can carry the engine's last actions as engine_log_tail. + if (level >= 1) AnalyticsService.appendEngineLog(level, text) } } } diff --git a/app/src/main/java/at/dasher/android/LocaleHelper.kt b/app/src/main/java/at/dasher/android/LocaleHelper.kt new file mode 100644 index 0000000..089663c --- /dev/null +++ b/app/src/main/java/at/dasher/android/LocaleHelper.kt @@ -0,0 +1,37 @@ +package at.dasher.android + +import android.content.Context +import android.content.res.Configuration +import java.util.Locale + +/** + * RFC 0003: per-app locale for the frontend chrome. The engine's own labels are + * localised via `dasher_set_locale`; this wraps the Application's base context so + * the Compose UI's `strings.xml` resources follow the same user-chosen locale on + * all API levels (no AppCompat dependency needed). + * + * Flow: [setLocale] persists the code and the top Activity is recreated; + * [DasherApp.attachBaseContext] re-wraps on the next process start. + */ +object LocaleHelper { + private const val PREFS = "dasher_locale" + private const val KEY = "app_locale" + + fun wrap(base: Context): Context { + val code = base.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null) + ?: return base + val locale = Locale.forLanguageTag(code) + Locale.setDefault(locale) + val config = Configuration(base.resources.configuration) + config.setLocale(locale) + config.setLayoutDirection(locale) + return base.createConfigurationContext(config) + } + + fun setLocale(context: Context, code: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, code).apply() + } + + fun currentLanguageTag(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null) +} diff --git a/app/src/main/java/at/dasher/android/MainActivity.kt b/app/src/main/java/at/dasher/android/MainActivity.kt index 56215d6..56aa44c 100644 --- a/app/src/main/java/at/dasher/android/MainActivity.kt +++ b/app/src/main/java/at/dasher/android/MainActivity.kt @@ -53,15 +53,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.composables.icons.lucide.ClipboardCopy import com.composables.icons.lucide.Crosshair import com.composables.icons.lucide.FilePlus +import com.composables.icons.lucide.FolderOpen import com.composables.icons.lucide.Gauge import com.composables.icons.lucide.Gamepad2 import com.composables.icons.lucide.Hand @@ -72,6 +76,7 @@ import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Play import com.composables.icons.lucide.Save import com.composables.icons.lucide.Settings +import com.composables.icons.lucide.Share2 import com.composables.icons.lucide.Smartphone import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -89,6 +94,13 @@ class MainActivity : ComponentActivity() { // Observable UI state. private var outputText by mutableStateOf("") + /** Text loaded via "Open" — preserved as a prefix the engine appends to (Dasher writes forward). */ + private var loadedPrefix by mutableStateOf("") + /** Combined display text = loaded prefix + engine output since the last reset/open. */ + private val fullText: String get() = loadedPrefix + outputText + // Output-pane font (persisted locally; the canvas glyph font is SP_DASHER_FONT in the engine). + private var outputFontFamily by mutableStateOf("") + private var outputFontSize by mutableStateOf(16f) private var alphabets by mutableStateOf>(emptyList()) private var currentAlphabet by mutableStateOf("") private var palettes by mutableStateOf>(emptyList()) @@ -97,6 +109,7 @@ class MainActivity : ComponentActivity() { private var inputMode by mutableStateOf(InputMode.TOUCH) private var tiltAvailable by mutableStateOf(false) private var showSettings by mutableStateOf(false) + private var showAnalyticsPrompt by mutableStateOf(false) private var isPlaying by mutableStateOf(true) private var gameMode by mutableStateOf(false) private var gameState by mutableStateOf(null) @@ -117,10 +130,19 @@ class MainActivity : ComponentActivity() { if (uri != null) saveOutputTo(uri) } + // SAF launcher: opens a .txt and loads it as the output prefix (DESIGN.md §Toolbar "Open"). + private val openLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) openOutputFrom(uri) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - AnalyticsService.init(this) + // Analytics + crash handler are initialised in DasherApp (Application.onCreate). AnalyticsService.capture("app_launched", mapOf("locale" to java.util.Locale.getDefault().toLanguageTag())) + loadOutputFontPrefs() + // RFC 0001: first-run opt-in prompt (only if the user hasn't been asked yet). + showAnalyticsPrompt = !AnalyticsService.hasPrompted(this) // Tilt provider forwards normalised coords to the engine on the main thread // (the C API context is single-threaded; sensor callbacks arrive elsewhere). @@ -191,14 +213,14 @@ class MainActivity : ComponentActivity() { DasherAndroidTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { AppScreen( - output = outputText, + output = fullText, alphabets = alphabets, currentAlphabet = currentAlphabet, speedPercent = speedPercent, autoSpeed = autoSpeed, isPlaying = isPlaying, - onClear = { engine?.resetOutputText(); outputText = "" }, - onCopyAll = { copyToClipboard(outputText) }, + onClear = { engine?.resetOutputText(); outputText = ""; loadedPrefix = "" }, + onCopyAll = { copyToClipboard(fullText) }, onTogglePlay = { val eng = engine ?: return@AppScreen if (isPlaying) { eng.stop(); isPlaying = false } @@ -216,15 +238,44 @@ class MainActivity : ComponentActivity() { if (autoSpeedKey >= 0) { engine?.setBoolValue(autoSpeedKey, v); engine?.saveSettings() } }, onOpenSettings = { showSettings = true }, + onOpen = { openOutput() }, onSave = { saveOutput() }, + onShare = { shareOutput() }, + outputFontFamily = outputFontFamily, + outputFontSize = outputFontSize, gameMode = gameMode, gameState = gameState, onToggleGame = { toggleGame() } ) if (showSettings) SettingsScreen( engine = engine ?: return@Surface, - onDismiss = { showSettings = false } + onDismiss = { showSettings = false }, + outputFontFamily = outputFontFamily, + outputFontSize = outputFontSize, + onOutputFontChanged = { family, size -> + outputFontFamily = family; outputFontSize = size; saveOutputFontPrefs() + } ) + // RFC 0001: first-run analytics opt-in. No events are sent before this choice. + if (showAnalyticsPrompt) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { + AnalyticsService.setOptedIn(this@MainActivity, false); showAnalyticsPrompt = false + }, + title = { Text(stringResource(R.string.analytics_title)) }, + text = { Text(stringResource(R.string.analytics_body)) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + AnalyticsService.setOptedIn(this@MainActivity, true); showAnalyticsPrompt = false + }) { Text(stringResource(R.string.analytics_accept)) } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { + AnalyticsService.setOptedIn(this@MainActivity, false); showAnalyticsPrompt = false + }) { Text(stringResource(R.string.analytics_decline)) } + } + ) + } } } } @@ -314,7 +365,7 @@ class MainActivity : ComponentActivity() { } private fun saveOutput() { - if (outputText.isEmpty()) { + if (fullText.isEmpty()) { Toast.makeText(this, "Nothing to save", Toast.LENGTH_SHORT).show() return } @@ -323,13 +374,66 @@ class MainActivity : ComponentActivity() { private fun saveOutputTo(uri: android.net.Uri) { try { - contentResolver.openOutputStream(uri)?.use { it.write(outputText.toByteArray()) } + contentResolver.openOutputStream(uri)?.use { it.write(fullText.toByteArray()) } Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show() } catch (e: Exception) { Toast.makeText(this, "Save failed", Toast.LENGTH_SHORT).show() } } + private fun openOutput() { + openLauncher.launch(arrayOf("text/plain")) + } + + private fun openOutputFrom(uri: android.net.Uri) { + try { + val text = contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: return + // Load as a prefix the engine appends to. Dasher has no CAPI to seed the edit + // buffer (only get/reset output), so the loaded text is kept on the frontend and + // combined with the engine's append-since-reset output for display, save, copy. + loadedPrefix = text + engine?.resetOutputText() + outputText = "" + Toast.makeText(this, "Loaded ${text.length} chars", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(this, "Open failed", Toast.LENGTH_SHORT).show() + } + } + + private fun shareOutput() { + if (fullText.isEmpty()) { + Toast.makeText(this, "Nothing to share", Toast.LENGTH_SHORT).show() + return + } + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_TEXT, fullText) + } + startActivity(android.content.Intent.createChooser(intent, "Share text")) + } + + private fun loadOutputFontPrefs() { + val prefs = getSharedPreferences("dasher_output", android.content.Context.MODE_PRIVATE) + outputFontFamily = prefs.getString("font_family", "") ?: "" + outputFontSize = prefs.getFloat("font_size", 16f) + } + + private fun saveOutputFontPrefs() { + getSharedPreferences("dasher_output", android.content.Context.MODE_PRIVATE).edit().apply { + putString("font_family", outputFontFamily) + putFloat("font_size", outputFontSize) + apply() + } + } + + + private fun outputFontFamilyFor(name: String): FontFamily = when (name) { + "serif" -> FontFamily.Serif + "monospace" -> FontFamily.Monospace + "sans-serif" -> FontFamily.SansSerif + else -> FontFamily.Default + } + @Composable private fun AppScreen( output: String, @@ -345,7 +449,11 @@ class MainActivity : ComponentActivity() { onSpeedChanged: (Int) -> Unit, onAutoSpeedChanged: (Boolean) -> Unit, onOpenSettings: () -> Unit, + onOpen: () -> Unit, onSave: () -> Unit, + onShare: () -> Unit, + outputFontFamily: String, + outputFontSize: Float, gameMode: Boolean, gameState: GameState?, onToggleGame: () -> Unit @@ -353,7 +461,7 @@ class MainActivity : ComponentActivity() { Scaffold { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { TopBar(isPlaying = isPlaying, onClear = onClear, onTogglePlay = onTogglePlay, - onCopyAll = onCopyAll, onSave = onSave, + onCopyAll = onCopyAll, onOpen = onOpen, onSave = onSave, onShare = onShare, gameMode = gameMode, onToggleGame = onToggleGame, onOpenSettings = onOpenSettings) if (gameMode && gameState != null) GameTargetBar(gameState!!) @@ -361,9 +469,11 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxWidth().height(120.dp).padding(horizontal = 8.dp) ) { Text( - text = output.ifEmpty { "Touch the canvas to start writing." }, + text = output.ifEmpty { stringResource(R.string.output_placeholder) }, modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(4.dp), - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, + fontFamily = outputFontFamilyFor(outputFontFamily), + fontSize = outputFontSize.sp ) } @@ -397,27 +507,33 @@ class MainActivity : ComponentActivity() { onClear: () -> Unit, onTogglePlay: () -> Unit, onCopyAll: () -> Unit, + onOpen: () -> Unit, onSave: () -> Unit, + onShare: () -> Unit, gameMode: Boolean, onToggleGame: () -> Unit, onOpenSettings: () -> Unit ) { - // DESIGN.md §Top Toolbar: New, Play/Pause, Copy, Save, Game, Prefs — Lucide icons (RFC 0002). + // DESIGN.md §Top Toolbar — Lucide icons (RFC 0002). Mirrors Apple/Windows order. Surface(color = MaterialTheme.colorScheme.surface) { Row( modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - ToolbarButton(Lucide.FilePlus, "New / clear output", onClear) + ToolbarButton(Lucide.FilePlus, stringResource(R.string.toolbar_new), onClear) + ToolbarButton(Lucide.FolderOpen, stringResource(R.string.toolbar_open), onOpen) ToolbarButton(if (isPlaying) Lucide.Pause else Lucide.Play, - if (isPlaying) "Pause" else "Play", onTogglePlay) - ToolbarButton(Lucide.ClipboardCopy, "Copy all", onCopyAll) - ToolbarButton(Lucide.Save, "Save to file", onSave) + stringResource(if (isPlaying) R.string.toolbar_pause else R.string.toolbar_play), + onTogglePlay) + ToolbarButton(Lucide.ClipboardCopy, stringResource(R.string.toolbar_copy), onCopyAll) + ToolbarButton(Lucide.Save, stringResource(R.string.toolbar_save), onSave) + ToolbarButton(Lucide.Share2, stringResource(R.string.toolbar_share), onShare) Spacer(Modifier.weight(1f)) - ToolbarButton(Lucide.Gamepad2, if (gameMode) "Leave game mode" else "Game mode", + ToolbarButton(Lucide.Gamepad2, + stringResource(if (gameMode) R.string.toolbar_game_leave else R.string.toolbar_game), onToggleGame) - ToolbarButton(Lucide.Settings, "Settings", onOpenSettings) + ToolbarButton(Lucide.Settings, stringResource(R.string.toolbar_settings), onOpenSettings) } } } diff --git a/app/src/main/java/at/dasher/android/SettingsScreen.kt b/app/src/main/java/at/dasher/android/SettingsScreen.kt index dfd3a48..95426f0 100644 --- a/app/src/main/java/at/dasher/android/SettingsScreen.kt +++ b/app/src/main/java/at/dasher/android/SettingsScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Lucide @@ -58,7 +59,13 @@ import com.composables.icons.lucide.X */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(engine: DasherEngine, onDismiss: () -> Unit) { +fun SettingsScreen( + engine: DasherEngine, + onDismiss: () -> Unit, + outputFontFamily: String = "", + outputFontSize: Float = 16f, + onOutputFontChanged: (String, Float) -> Unit = { _, _ -> } +) { var params by remember { mutableStateOf(engine.allParameters()) } // Bumped on every change so rows re-read fresh values from the engine. var version by remember { mutableIntStateOf(0) } @@ -66,6 +73,11 @@ fun SettingsScreen(engine: DasherEngine, onDismiss: () -> Unit) { // Re-fetch the schema (labels re-translate after a locale change). val reload: () -> Unit = { params = engine.allParameters(); version++ } + // RFC 0006: Simple/Advanced progressive disclosure. The engine exposes a binary + // `advanced` flag per parameter; Simple (default) hides advanced params, matching + // Apple's tier filter. A 3-tier (common/advanced/expert) needs a future CAPI. + var simpleMode by remember { mutableStateOf(true) } + val tabs = remember(params) { val order = listOf("Input", "Language", "Customization", "Output", "Game Mode") val present = params.map { it.group.ifEmpty { "Input" } }.distinct() @@ -79,16 +91,26 @@ fun SettingsScreen(engine: DasherEngine, onDismiss: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.settings_title)) }, navigationIcon = { IconButton(onClick = { engine.saveSettings(); onDismiss() }) { - Icon(Lucide.X, contentDescription = "Close") + Icon(Lucide.X, contentDescription = stringResource(R.string.settings_close)) } } ) } ) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { + // RFC 0006: Simple/Advanced disclosure toggle. + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(R.string.settings_advanced), style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f)) + Switch(checked = !simpleMode, onCheckedChange = { simpleMode = !it }) + } PrimaryScrollableTabRow( selectedTabIndex = selectedTab, edgePadding = 8.dp, @@ -105,13 +127,15 @@ fun SettingsScreen(engine: DasherEngine, onDismiss: () -> Unit) { ) } } - val rows = remember(params, tabGroup) { + val rows = remember(params, tabGroup, simpleMode) { params.filter { val g = it.group.ifEmpty { "Input" } g == tabGroup && // The colour palette param is rendered as the swatch picker in // the Customization tab (AppearanceSection), not as a dropdown. - !(tabGroup == "Customization" && it.name.contains("colour", true) && it.name.contains("palette", true)) + !(tabGroup == "Customization" && it.name.contains("colour", true) && it.name.contains("palette", true)) && + // RFC 0006: hide advanced params in Simple mode. + !(simpleMode && it.advanced != 0) } } LazyColumn(modifier = Modifier.fillMaxSize()) { @@ -125,6 +149,9 @@ fun SettingsScreen(engine: DasherEngine, onDismiss: () -> Unit) { if (tabGroup == "Customization") { item { AppearanceSection(engine, bump) } } + if (tabGroup == "Output") { + item { OutputFontSection(outputFontFamily, outputFontSize, onOutputFontChanged) } + } items(rows, key = { it.key }) { p -> ParameterRow(engine, p, version, bump) } @@ -175,6 +202,7 @@ private fun LocaleRow(engine: DasherEngine, reload: () -> Unit) { var expanded by remember { mutableStateOf(false) } var current by remember { mutableStateOf(engine.locale()) } val label = DASHER_LOCALES.firstOrNull { it.first == current }?.second ?: current + val ctx = androidx.compose.ui.platform.LocalContext.current Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -192,7 +220,13 @@ private fun LocaleRow(engine: DasherEngine, reload: () -> Unit) { DropdownMenuItem( text = { Text(name) }, onClick = { - if (engine.setLocale(code)) { current = code; reload() } + if (engine.setLocale(code)) { + current = code + LocaleHelper.setLocale(ctx, code) + reload() + // Recreate so the Compose UI chrome re-localises too (RFC 0003). + (ctx as? android.app.Activity)?.recreate() + } expanded = false } ) @@ -390,6 +424,48 @@ private fun TrainingSection(engine: DasherEngine) { } } +/** Output-pane font family + size (frontend-local pref; canvas glyph font is SP_DASHER_FONT). */ +@Composable +private fun OutputFontSection( + family: String, + size: Float, + onChange: (String, Float) -> Unit +) { + val options = listOf( + "" to "System", "sans-serif" to "Sans Serif", + "serif" to "Serif", "monospace" to "Monospace" + ) + var expanded by remember { mutableStateOf(false) } + val label = options.firstOrNull { it.first == family }?.second ?: "System" + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Output text", style = MaterialTheme.typography.titleMedium) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Font", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Box { + OutlinedButton(onClick = { expanded = true }) { Text(label) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (value, name) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { onChange(value, size); expanded = false } + ) + } + } + } + } + Text("Size: ${size.toInt()}", style = MaterialTheme.typography.bodyLarge) + Slider( + value = size, + onValueChange = { onChange(family, it) }, + valueRange = 10f..48f, + steps = 0 + ) + } +} + @Composable private fun ParameterRow(engine: DasherEngine, p: ParameterInfo, version: Int, onChange: () -> Unit) { // `version` is read so the row recomposes and re-reads the engine after a change. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..32048d1 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,36 @@ + + + Dasher + Dasher-Tastatur + dasher.txt + + + Neu / Ausgabe löschen + Datei öffnen + Starten + Pause + Alles kopieren + In Datei speichern + Teilen + Spielmodus + Spielmodus verlassen + Einstellungen + + + Einstellungen + Schließen + Erweiterte Einstellungen + + + Dasher verbessern helfen + Anonyme Nutzungsstatistiken helfen uns zu verstehen, wie Dasher verwendet wird, und Abstürze zu beheben. Es werden keine getippten Texte, Zwischenablageinhalte oder persönlichen Daten erhoben. Deaktivierung jederzeit unter Einstellungen → Datenschutz. + Dasher verbessern helfen + Nicht jetzt + + + Berühre die Leinwand, um zu schreiben. + Nichts zu speichern + Nichts zu teilen + Gespeichert + Speichern fehlgeschlagen + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 206eee7..884282b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,34 @@ Dasher Dasher Keyboard dasher.txt + + + New / clear output + Open file + Play + Pause + Copy all + Save to file + Share + Game mode + Leave game mode + Settings + + + Settings + Close + Advanced settings + + + Help improve Dasher + Anonymous usage analytics help us understand how Dasher is used and fix crashes. No typed text, clipboard contents, or personal information is ever collected. Opt out any time in Settings → Privacy. + Help improve Dasher + Not now + + + Touch the canvas to start writing. + Nothing to save + Nothing to share + Saved + Save failed