Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions analytics-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@
"enabled": { "type": "boolean" }
},
"pii": false
},
"ui_action": {
"description": "User activated a control in the main UI (outside settings windows) — RFC 0001 2026-10-28 amendment. Answers 'where should main-UI focus go'.",
"properties": {
"surface": { "type": "string", "enum": ["main_toolbar", "status_bar", "ime_toolbar", "ime_topbar"] },
"action": { "type": "string", "description": "Fixed vocabulary; new identifiers require a schema update", "examples": ["new", "open", "save", "pause_play", "game_mode", "settings", "copy", "share", "emoji_toggle", "speed_up", "speed_down", "layout", "float", "dock", "hide", "cursor_left", "cursor_right", "backspace", "select_all", "paste"] },
"state": { "type": "string", "enum": ["on", "off", "paused", "playing", "text", "emoji"], "description": "Optional — for toggles" }
},
"pii": false
},
"setting_changed": {
"description": "User committed a settings change — RFC 0001 2026-10-28 amendment. Answers 'which settings are the priority'. Fires at the widget commit site only (never for programmatic engine changes).",
"properties": {
"setting_id": { "type": "string", "description": "Canonical identifier: DasherCore parameter name or frontend setting key", "examples": ["Max Bitrate", "output_font_size"] },
"value_type": { "type": "string", "enum": ["bool", "number", "enum", "string"] },
"value": { "type": ["boolean", "number", "string"], "description": "Present ONLY for bool/number/enum (fixed vocabularies). NEVER for string settings — free text is not collected" },
"surface": { "type": "string", "enum": ["settings", "main_ui", "ime"] }
},
"pii": false
}
},
"explicitly_not_collected": [
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/at/dasher/android/DasherImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ class DasherImeService : InputMethodService() {
val eng = engine ?: return
val btn = emojiToggleBtn ?: return
val current = eng.getCurrentAlphabet()
// RFC 0001 amendment: the cross-platform aggregate (the IME's own
// alphabet events already capture the alphabet switch itself).
UiTelemetry.action("ime_topbar", "emoji_toggle", if (current == "Emoji") "text" else "emoji")
if (current == "Emoji") {
val target = emojiReturnAlphabet
if (target.isNullOrEmpty() || target == "Emoji") {
Expand Down Expand Up @@ -936,10 +939,21 @@ class DasherImeService : InputMethodService() {
setImageDrawable(LucideIcons.tinted(context, iconRes, topBarTint()))
background = pillBackground()
tooltipTextCompat(description)
setOnClickListener { onClick() }
setOnClickListener {
UiTelemetry.action("ime_topbar", topbarActionIdFor(description))
onClick()
}
}
}

/** RFC 0001 amendment vocabulary for the IME top bar. */
private fun topbarActionIdFor(description: String): String = when (description) {
"Float the keyboard" -> "float"
"Dock the keyboard" -> "dock"
"Hide the keyboard" -> "hide"
else -> description.lowercase().replace(' ', '_')
}

/**
* The toolbars' bordered pill with a real pressed state — rounded.md 8dp
* and the highlight tokens for the press fill. The STROKE deliberately
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/at/dasher/android/EditingToolbar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,25 @@ class EditingToolbar(
large: Boolean = false, mutates: Boolean = true, action: () -> Unit) {
addView(toolButton(iconRes, tooltip, large).apply {
setOnClickListener {
// RFC 0001 amendment: which IME editing tools are actually
// used (Heide's SwiftKey round-trips suggested these matter).
UiTelemetry.action("ime_toolbar", actionIdFor(tooltip))
action()
if (mutates) onBufferChanged()
}
})
}

private fun actionIdFor(tooltip: String): String = when (tooltip) {
"Move cursor left" -> "cursor_left"
"Move cursor right" -> "cursor_right"
"Delete" -> "backspace"
"Select all" -> "select_all"
"Copy" -> "copy"
"Paste" -> "paste"
else -> tooltip.lowercase().replace(' ', '_')
}

private fun addSpacer(widthPx: Int) {
addView(android.view.View(context).apply {
layoutParams = LayoutParams(widthPx, 1)
Expand Down
55 changes: 45 additions & 10 deletions app/src/main/java/at/dasher/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,14 @@ class MainActivity : ComponentActivity() {
},
onSpeedChanged = { pct ->
speedPercent = pct; engine?.setSpeedPercent(pct); engine?.saveSettings()
// RFC 0001 amendment: speed from the status bar vs.
// settings answers different UX questions.
UiTelemetry.setting("Speed Percent", pct.toLong(), "main_ui")
},
onAutoSpeedChanged = { v ->
autoSpeed = v
if (autoSpeedKey >= 0) { engine?.setBoolValue(autoSpeedKey, v); engine?.saveSettings() }
UiTelemetry.setting("Auto Speed", v, "main_ui")
},
onOpenSettings = { showSettings = true },
onOpen = { openOutput() },
Expand Down Expand Up @@ -479,6 +483,10 @@ class MainActivity : ComponentActivity() {
eng.saveSettings()
pinAlphabetChoice()
AnalyticsService.capture("emoji_toggled", mapOf("active" to (currentAlphabet == "Emoji")))
// RFC 0001 2026-10-28 amendment vocabulary (emoji_toggled stays for
// continuity; ui_action is the cross-platform aggregate).
UiTelemetry.action("status_bar", "emoji_toggle",
if (currentAlphabet == "Emoji") "emoji" else "text")
}

/**
Expand Down Expand Up @@ -858,26 +866,53 @@ class MainActivity : ComponentActivity() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
ToolbarButton(Lucide.FilePlus, stringResource(R.string.toolbar_new), onClear)
ToolbarButton(Lucide.FolderOpen, stringResource(R.string.toolbar_open), onOpen)
ToolbarButton(Lucide.FilePlus, stringResource(R.string.toolbar_new), onClear) {
UiTelemetry.action("main_toolbar", "new")
}
ToolbarButton(Lucide.FolderOpen, stringResource(R.string.toolbar_open), onOpen) {
UiTelemetry.action("main_toolbar", "open")
}
ToolbarButton(if (isPlaying) Lucide.Pause else Lucide.Play,
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)
onTogglePlay) {
// state before the toggle: 'playing' means the user just paused
UiTelemetry.action("main_toolbar", "pause_play",
if (isPlaying) "paused" else "playing")
}
ToolbarButton(Lucide.ClipboardCopy, stringResource(R.string.toolbar_copy), onCopyAll) {
UiTelemetry.action("main_toolbar", "copy")
}
ToolbarButton(Lucide.Save, stringResource(R.string.toolbar_save), onSave) {
UiTelemetry.action("main_toolbar", "save")
}
ToolbarButton(Lucide.Share2, stringResource(R.string.toolbar_share), onShare) {
UiTelemetry.action("main_toolbar", "share")
}
Spacer(Modifier.weight(1f))
ToolbarButton(Lucide.Gamepad2,
stringResource(if (gameMode) R.string.toolbar_game_leave else R.string.toolbar_game),
onToggleGame)
ToolbarButton(Lucide.Settings, stringResource(R.string.toolbar_settings), onOpenSettings)
onToggleGame) {
UiTelemetry.action("main_toolbar", "game_mode",
if (gameMode) "off" else "on")
}
ToolbarButton(Lucide.Settings, stringResource(R.string.toolbar_settings), onOpenSettings) {
UiTelemetry.action("main_toolbar", "settings")
}
}
}
}

@Composable
private fun ToolbarButton(icon: ImageVector, description: String, onClick: () -> Unit) {
IconButton(onClick = onClick) {
private fun ToolbarButton(
icon: ImageVector,
description: String,
onClick: () -> Unit,
onTelemetry: (() -> Unit)? = null,
) {
IconButton(onClick = {
onTelemetry?.invoke()
onClick()
}) {
Icon(icon, contentDescription = description, tint = MaterialTheme.colorScheme.onSurface)
}
}
Expand Down
71 changes: 55 additions & 16 deletions app/src/main/java/at/dasher/android/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ private fun KeyboardStartSection() {
// #75 review M7: how many users need the escape hatch is the
// strongest signal about whether the default behaviour is wrong.
AnalyticsService.capture("ime_always_docked_changed", mapOf("enabled" to v))
// RFC 0001 amendment: the cross-platform aggregate for the UX team.
UiTelemetry.setting("Keyboard always starts docked", v, "settings")
}
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
// Accessible toggle (#75 review M8): the whole row is the touch
Expand Down Expand Up @@ -318,6 +320,8 @@ private fun InputMethodRow(
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.input_method_title), style = MaterialTheme.typography.bodyLarge)
// RFC 0001 amendment note: the aggregate lives in setting_changed;
// input_method_changed (v1 schema) stays for continuity.
Text("How you steer the Dasher pointer. Other modes appear when the hardware supports them.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Expand Down Expand Up @@ -346,6 +350,15 @@ private fun InputMethodRow(
onClick = {
onSelect(mode)
expanded = false
UiTelemetry.setting(
"Input Method",
when (mode) {
InputMode.TOUCH -> "touch"
InputMode.TILT -> "tilt"
InputMode.JOYSTICK -> "joystick"
},
"settings",
)
}
)
}
Expand Down Expand Up @@ -517,7 +530,10 @@ private fun PaletteSwatchRow(
modifier = Modifier
.height(40.dp)
.width(56.dp)
.then(Modifier.clickable { onSelect(p.name) })
.then(Modifier.clickable {
onSelect(p.name)
UiTelemetry.setting("Colour Palette", p.name, "settings")
})
.border(
width = if (isSelected) 2.dp else 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary
Expand Down Expand Up @@ -653,16 +669,24 @@ private fun OutputFontSection(
options.forEach { (value, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = { onChange(value, size); expanded = false }
onClick = {
onChange(value, size); expanded = false
UiTelemetry.setting("output_font_family", value.ifEmpty { "System" }, "settings")
}
)
}
}
}
}
Text("Size: ${size.toInt()}", style = MaterialTheme.typography.bodyLarge)
var pendingSize by remember { mutableStateOf(size) }
Slider(
value = size,
onValueChange = { onChange(family, it) },
onValueChange = { pendingSize = it; onChange(family, it) },
onValueChangeFinished = {
// RFC 0001 amendment: one event per committed drag, not per frame.
UiTelemetry.setting("output_font_size", pendingSize.toInt().toLong(), "settings")
},
valueRange = 10f..48f,
steps = 0
)
Expand All @@ -686,24 +710,27 @@ private fun ParameterRow(engine: DasherEngine, p: ParameterInfo, version: Int, o
}
}
when {
p.isBool -> BoolControl(engine, p.key, onChange)
p.isBool -> BoolControl(engine, p, onChange)
p.isLong -> LongControl(engine, p, onChange)
p.isString -> StringControl(engine, p, onChange)
}
}
}

@Composable
private fun BoolControl(engine: DasherEngine, key: Int, onChange: () -> Unit) {
var checked by remember { mutableStateOf(engine.boolValue(key)) }
Switch(checked = checked, onCheckedChange = { v -> checked = v; engine.setBoolValue(key, v); onChange() })
private fun BoolControl(engine: DasherEngine, p: ParameterInfo, onChange: () -> Unit) {
var checked by remember { mutableStateOf(engine.boolValue(p.key)) }
Switch(checked = checked, onCheckedChange = { v ->
checked = v; engine.setBoolValue(p.key, v); onChange()
UiTelemetry.setting(p.name, v, "settings")
})
}

@Composable
private fun LongControl(engine: DasherEngine, p: ParameterInfo, onChange: () -> Unit) {
val enums = remember(p.key) { engine.enumValues(p.key) }
when {
enums.isNotEmpty() -> EnumDropdown(engine, p.key, enums, onChange)
enums.isNotEmpty() -> EnumDropdown(engine, p, enums, onChange)
p.max > p.min -> LongSlider(engine, p, onChange)
else -> Text("${engine.longValue(p.key)}", style = MaterialTheme.typography.bodyMedium)
}
Expand All @@ -717,7 +744,10 @@ private fun LongSlider(engine: DasherEngine, p: ParameterInfo, onChange: () -> U
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { engine.setLongValue(p.key, value.toLong()); onChange() },
onValueChangeFinished = {
engine.setLongValue(p.key, value.toLong()); onChange()
UiTelemetry.setting(p.name, value.toLong(), "settings")
},
valueRange = p.min.toFloat()..p.max.toFloat(),
steps = if (p.step > 0) ((p.max - p.min) / p.step).coerceAtLeast(1).toInt() - 1 else 0
)
Expand All @@ -726,18 +756,22 @@ private fun LongSlider(engine: DasherEngine, p: ParameterInfo, onChange: () -> U

@Composable
private fun EnumDropdown(
engine: DasherEngine, key: Int, options: List<Pair<String, Int>>, onChange: () -> Unit
engine: DasherEngine, p: ParameterInfo, options: List<Pair<String, Int>>, onChange: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var current by remember { mutableStateOf(engine.longValue(key).toInt()) }
var current by remember { mutableStateOf(engine.longValue(p.key).toInt()) }
val label = options.firstOrNull { it.second == current }?.first ?: "$current"
Box {
OutlinedButton(onClick = { expanded = true }) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (name, value) ->
DropdownMenuItem(
text = { Text(name) },
onClick = { current = value; engine.setLongValue(key, value.toLong()); expanded = false; onChange() }
onClick = {
current = value; engine.setLongValue(p.key, value.toLong()); expanded = false; onChange()
// Engine enums are fixed vocabularies (permitted values)
UiTelemetry.setting(p.name, name, "settings")
}
)
}
}
Expand All @@ -754,26 +788,31 @@ private fun StringControl(engine: DasherEngine, p: ParameterInfo, onChange: () -
remember(p.key) { engine.stringValues(p.key) }.map { it to it }
}
if (options.isNotEmpty()) {
StringDropdown(engine, p.key, options, onChange)
StringDropdown(engine, p, options, onChange)
} else {
Text(engine.stringValue(p.key), style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}

@Composable
private fun StringDropdown(
engine: DasherEngine, key: Int, options: List<Pair<String, String>>, onChange: () -> Unit
engine: DasherEngine, p: ParameterInfo, options: List<Pair<String, String>>, onChange: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var current by remember { mutableStateOf(engine.stringValue(key)) }
var current by remember { mutableStateOf(engine.stringValue(p.key)) }
val label = options.firstOrNull { it.first == current }?.second ?: current.ifEmpty { "Default" }
Box {
OutlinedButton(onClick = { expanded = true }) { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = { current = value; engine.setStringValue(key, value); expanded = false; onChange() }
onClick = {
current = value; engine.setStringValue(p.key, value); expanded = false; onChange()
// Options come from fixed vocabularies (fonts, permitted
// values) — RFC 0001 amendment allows enum values.
UiTelemetry.setting(p.name, value.ifEmpty { "System" }, "settings")
}
)
}
}
Expand Down
Loading
Loading