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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface WidgetsStrings {
val service: String
val levelStrip: String
val rigPosition: String
val actionButton: String

/** Their settings. */
val opens: String
Expand All @@ -22,6 +23,23 @@ interface WidgetsStrings {
val on: String
val unavailable: String

/** The action button's setting, and the actions it can be pointed at — short, for a thumb-sized tile. */
val action: String
val engine: String
val speedSync: String
val cruise: String
val beacon: String
val lowBeam: String
val highBeam: String
val workFront: String
val workRear: String
val hazard: String
val fold: String
val power: String
val lower: String
val slotSelected: String
fun actionAt(action: String, slot: String): String

/** The picker and the config dialog. */
val addWidget: String
val noWidgetFits: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal object DeWidgets : WidgetsStrings {
override val service = "Service"
override val levelStrip = "Füllstandsleiste"
override val rigPosition = "Gespannposition"
override val actionButton = "Aktionstaste"

override val opens = "Öffnet"
override val lamps = "Leuchten"
Expand All @@ -21,6 +22,22 @@ internal object DeWidgets : WidgetsStrings {
override val on = "An"
override val unavailable = "Nicht verfügbar"

override val action = "Aktion"
override val engine = "Motor"
override val speedSync = "Geschwindigkeit koppeln"
override val cruise = "Tempomat"
override val beacon = "Rundumleuchte"
override val lowBeam = "Abblendlicht"
override val highBeam = "Fernlicht"
override val workFront = "Arbeitslicht vorne"
override val workRear = "Arbeitslicht hinten"
override val hazard = "Warnblinker"
override val fold = "Klappen"
override val power = "Einschalten"
override val lower = "Absenken"
override val slotSelected = "Ausgewählt"
override fun actionAt(action: String, slot: String): String = "$action · $slot"

override val addWidget = "WIDGET HINZUFÜGEN"
override val noWidgetFits = "Kein Widget passt in diesen Platz."
override val nothingToChoose = "Gerade gibt es nichts zur Auswahl."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal object EnWidgets : WidgetsStrings {
override val service = "Service"
override val levelStrip = "Level Strip"
override val rigPosition = "Rig Position"
override val actionButton = "Action Button"

override val opens = "Opens"
override val lamps = "Lamps"
Expand All @@ -21,6 +22,22 @@ internal object EnWidgets : WidgetsStrings {
override val on = "On"
override val unavailable = "Unavailable"

override val action = "Action"
override val engine = "Engine"
override val speedSync = "Speed sync"
override val cruise = "Cruise control"
override val beacon = "Beacon"
override val lowBeam = "Low beam"
override val highBeam = "High beam"
override val workFront = "Front work lights"
override val workRear = "Rear work lights"
override val hazard = "Hazard lights"
override val fold = "Fold"
override val power = "Power"
override val lower = "Lower"
override val slotSelected = "Selected"
override fun actionAt(action: String, slot: String): String = "$action · $slot"

override val addWidget = "ADD WIDGET"
override val noWidgetFits = "No widget fits this slot."
override val nothingToChoose = "Nothing to choose from right now."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ private data class RigSlotState(
val broken: Boolean = false,
)

private fun findImplement(list: List<Implement>, pos: String): Implement? {
/**
* The machine at export position [pos], searched depth-first so a chain's head wins over anything
* hitched behind it. Internal so the action buttons resolve a position to the same machine this
* panel shows for it — two tiles naming "Rear" must never be reading different implements.
*/
internal fun findImplement(list: List<Implement>, pos: String): Implement? {
for (imp in list) {
if (imp.position == pos) return imp
findImplement(imp.implement, pos)?.let { return it }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package net.vertexdezign.vdt.app.widgets

import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import net.vertexdezign.vdt.app.i18n.LocalStrings
import net.vertexdezign.vdt.app.i18n.Strings
import net.vertexdezign.vdt.app.state.LocalVdtStore
import net.vertexdezign.vdt.app.theme.VdtColors

/**
* One switch on the vehicle as a tile of its own: Speed Sync, a light, the engine, or fold/power/lower
* for one position on the rig (issue #194). It works like [ShortcutWidget], except that a tap acts on
* the machine instead of opening an app, and the tile shows the state of what it switches.
*
* It is one configurable type with the action as per-instance config, not one type per action. The
* reasons are the shortcut's: one row in the picker however many actions there are, and as many
* buttons on a page as anyone wants. The actions are [TileActions].
*
* The tile has three looks. **On** is a filled green square with [VdtColors.OnFill] ink. **Off** is the
* plain panel square. **Unavailable** (nothing to switch: no vehicle, no beacon, nothing hitched at the
* rear) keeps the action's icon greyed out and says so, so a tile placed for a machine that is not
* hitched right now still shows what it is for. On and off differ in brightness and in the state word
* under the label, never in hue alone. A button the game would refuse right now (Speed Sync with
* nothing in range) shows its state but ignores taps.
*
* Every action is offered all the time, unlike the shortcut's apps: whether a switch exists depends on
* the vehicle being driven, not on the session, and a picker that changed every time you changed
* tractors would be hard to use.
*/
object ActionWidget : Widget {
override val id = "action"
override fun title(strings: Strings) = strings.widgets.actionButton
override val icon: ImageVector = Icons.Filled.TouchApp

// Thumb-sized, like the shortcut: a row of these along an edge is a switch panel.
override val minWidth = 56.dp
override val minHeight = 56.dp

/** The config key naming the [TileAction] this button performs. */
const val ACTION_KEY = "action"

@Composable
private fun actionOption() = ConfigOption(
key = ACTION_KEY,
label = LocalStrings.current.widgets.action,
choices = TileActions.all.map { ConfigOption.Choice(it.id, it.title(LocalStrings.current), it.icon) },
)

@Composable
override fun configOptions(): List<ConfigOption> = listOf(actionOption())

@Composable
override fun Content(modifier: Modifier, config: WidgetConfig) {
val strings = LocalStrings.current
val store = LocalVdtStore.current
val telemetry by store.telemetry.collectAsState()
val network by store.autoDrive.collectAsState()

// An id no longer in the catalog (an action renamed away) says so instead of quietly becoming the
// first action. Switching the engine off is not a good thing to do by default.
val action = actionOption().resolve(config)?.let(TileActions::byId)
if (action == null) {
IconTile(
Icons.AutoMirrored.Filled.HelpOutline,
strings.widgets.unavailable,
modifier,
tint = VdtColors.TextDisabled,
)
return
}

val title = action.title(strings)
val state = action.stateOf(telemetry?.vehicle, network)
if (state == null) {
IconTile(
action.icon,
title,
modifier,
interactionModifier = Modifier.semantics { contentDescription = "$title, ${strings.widgets.unavailable}" },
tint = VdtColors.TextDisabled,
labelColor = VdtColors.TextDisabled,
caption = strings.widgets.unavailable,
)
return
}

val command = state.command
val word = state.word.label(strings)
val ink =
when {
// On wins over inert: grey ink on the green fill would be unreadable, and the state is the
// part the driver needs to read.
state.on -> VdtColors.OnFill

command == null -> VdtColors.TextDisabled

else -> VdtColors.DarkGray
}
IconTile(
state.icon,
title,
modifier,
interactionModifier =
Modifier
.semantics {
contentDescription = title
stateDescription = word
}
.toggleable(value = state.on, enabled = command != null, role = Role.Switch) {
command?.let(store.onCommand)
},
tint = ink,
labelColor = ink,
background = if (state.on) VdtColors.Green else VdtColors.Panel,
border = if (state.on) VdtColors.Green else VdtColors.PanelBorder,
caption = word,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -105,53 +106,96 @@ object ShortcutWidget : Widget {
}
}

/** The shortcut's tile: [IconTile], tappable when there is somewhere to go. A null [onClick] renders it inert. */
@Composable
private fun ShortcutTile(
icon: ImageVector,
label: String,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
tint: Color = VdtColors.Green,
) {
IconTile(
icon,
label,
modifier,
interactionModifier = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick),
tint = tint,
)
}

/**
* The icon-and-label tile. It centres a square inside whatever tile it was given rather than
* stretching to fill it: a tile is whatever share of a split it holds, so the same shortcut can be
* square on one screen and tall and narrow on another, and an icon that tracked those bounds would
* visibly distort on rotation. The square is also capped, so a shortcut someone has deliberately
* given a big region stays an icon instead of becoming a billboard.
* The icon-and-label tile shared by the shortcut and the action button. It centres a square inside
* whatever tile it was given rather than stretching to fill it: a tile is whatever share of a split it
* holds, so the same shortcut can be square on one screen and tall and narrow on another, and an icon
* that tracked those bounds would visibly distort on rotation. The square is also capped, so a shortcut
* someone has deliberately given a big region stays an icon instead of becoming a billboard.
*
* A null [onClick] renders the tile inert — it has nothing to open.
* [interactionModifier] is applied to the square, after the clip, so the ripple stays inside the rounded
* corners. [caption] is a third line under the label (the action button's state word). It is dropped
* when the square is too small for three lines, where it would push the icon out of the tile.
*/
@Composable
private fun ShortcutTile(
internal fun IconTile(
icon: ImageVector,
label: String,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
interactionModifier: Modifier = Modifier,
tint: Color = VdtColors.Green,
labelColor: Color = VdtColors.DarkGray,
background: Color = VdtColors.Panel,
border: Color = VdtColors.PanelBorder,
caption: String? = null,
) {
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
val side = minOf(maxWidth, maxHeight).coerceAtMost(MAX_TILE_SIDE)
val showCaption = caption != null && side >= MIN_CAPTION_SIDE
Column(
Modifier
.size(side)
.clip(RoundedCornerShape(8.dp))
.background(VdtColors.Panel)
.border(1.dp, VdtColors.PanelBorder, RoundedCornerShape(8.dp))
.then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
.background(background)
.border(1.dp, border, RoundedCornerShape(8.dp))
.then(interactionModifier)
.padding(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
verticalArrangement = Arrangement.spacedBy(if (showCaption) 2.dp else 6.dp, Alignment.CenterVertically),
) {
Icon(
icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size((side * 0.42f).coerceIn(20.dp, 48.dp)),
modifier = Modifier.size((side * if (showCaption) 0.34f else 0.42f).coerceIn(20.dp, 48.dp)),
)
Text(
label.uppercase(),
fontSize = 10.sp,
color = VdtColors.DarkGray,
color = labelColor,
textAlign = TextAlign.Center,
maxLines = 2,
// Two lines leave no room for the caption in a small square.
maxLines = if (showCaption && side < MAX_TILE_SIDE) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
if (showCaption) {
Text(
caption.uppercase(),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = labelColor,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

/**
* Below this an icon, a label and a caption cannot all fit in the square, so the caption is dropped.
* The square's fill still shows the state.
*/
private val MIN_CAPTION_SIDE = 76.dp

/** Past this the icon stops reading as an icon and starts reading as a mistake. */
private val MAX_TILE_SIDE = 140.dp
Loading
Loading