Refactor tile layout to show recent cases

The tile layout has been refactored to display a list of recent cases
instead of the currently active case. This change includes:

- Removing the "current case" view and its associated logic.
- Implementing a new `caseListContent` function to render a list of up
  to 8 recent cases.
- Modifying the main `buildTileLayout` to integrate the new case list
  and a prominent "Neu" (New) button.
- Updating the styling and click handling to reflect the new layout and
  functionality.
This commit is contained in:
2026-05-03 21:41:13 +02:00
parent d5f234e159
commit 8ce2e54847
3 changed files with 64 additions and 72 deletions
@@ -14,8 +14,8 @@ private const val RESOURCES_VERSION = "1"
/**
* Serves the single Doctate Tile. Reads a non-blocking snapshot from
* [DoctateApp.caseStore] and renders the "glance at current case" view with
* three tap regions. Refreshes are triggered externally via
* [DoctateApp.caseStore] and renders the recent-cases list with an
* edge-style "Neu" button. Refreshes are triggered externally via
* `TileService.getUpdater(ctx).requestUpdate(...)` — the App wires that up
* to the case-store flow.
*/
@@ -24,9 +24,9 @@ class DoctateTileService : TileService() {
requestParams: RequestBuilders.TileRequest,
): ListenableFuture<TileBuilders.Tile> {
val app = applicationContext as DoctateApp
val current = app.caseStore.currentCase
val cases = app.caseStore.casesFlow.value
val layout = LayoutElementBuilders.Layout.Builder()
.setRoot(buildTileLayout(packageName, current))
.setRoot(buildTileLayout(packageName, cases))
.build()
val timeline = TimelineBuilders.Timeline.Builder()
@@ -4,15 +4,11 @@ import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders.argb
import androidx.wear.protolayout.DimensionBuilders.dp
import androidx.wear.protolayout.DimensionBuilders.expand
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.LayoutElementBuilders.Box
import androidx.wear.protolayout.LayoutElementBuilders.Column
import androidx.wear.protolayout.LayoutElementBuilders.FontStyle
import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER
import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_END
import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_START
import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement
import androidx.wear.protolayout.LayoutElementBuilders.Row
import androidx.wear.protolayout.LayoutElementBuilders.Spacer
import androidx.wear.protolayout.LayoutElementBuilders.Text
import androidx.wear.protolayout.LayoutElementBuilders.VERTICAL_ALIGN_BOTTOM
@@ -22,7 +18,6 @@ import androidx.wear.protolayout.ModifiersBuilders.Clickable
import androidx.wear.protolayout.ModifiersBuilders.Corner
import androidx.wear.protolayout.ModifiersBuilders.Modifiers
import androidx.wear.protolayout.ModifiersBuilders.Padding
import androidx.wear.protolayout.TypeBuilders.StringProp
import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
@@ -30,101 +25,98 @@ import com.doctate.watch.presentation.NavCommand
private const val CLICK_ID_LIST = "list"
private const val CLICK_ID_NEW = "new"
private const val CLICK_ID_CONTINUE = "continue"
/** Build the root layout element for the Tile. Returns the "current case" view or an
* empty-state view depending on whether any case exists in [CaseStoreStub]. */
internal fun buildTileLayout(packageName: String, current: CaseEntry?): LayoutElement {
val column = Column.Builder()
private const val MAX_VISIBLE_CASES = 8
/**
* Build the root Tile layout: a vertical stack with the most recent cases
* pinned just above an edge-style "Neu" button. The whole list region is a
* single tap target — every line routes to the case list, regardless of
* which case was tapped. Up to [MAX_VISIBLE_CASES] cases are shown; the
* newest sits at the bottom (next to the edge button).
*/
internal fun buildTileLayout(packageName: String, cases: List<CaseEntry>): LayoutElement {
return Column.Builder()
.setWidth(expand())
.setHeight(expand())
.setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
.addContent(topRow(packageName))
.addContent(Spacer.Builder().setHeight(dp(8f)).build())
.addContent(middleContent(packageName, current))
.addContent(Spacer.Builder().setHeight(dp(8f)).build())
.addContent(Spacer.Builder().setHeight(dp(10f)).build())
.addContent(caseListContent(packageName, cases))
.addContent(Spacer.Builder().setHeight(dp(4f)).build())
.addContent(bottomNewButton(packageName))
.build()
}
private fun caseListContent(packageName: String, cases: List<CaseEntry>): LayoutElement {
val column = Column.Builder()
.setWidth(expand())
.setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
if (cases.isEmpty()) {
column.addContent(
Text.Builder()
.setText("Noch kein Fall heute")
.setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
.build(),
)
} else {
val visible = cases.take(MAX_VISIBLE_CASES).asReversed()
visible.forEachIndexed { index, case ->
if (index > 0) {
column.addContent(Spacer.Builder().setHeight(dp(2f)).build())
}
column.addContent(
Text.Builder()
.setText(formatLine(case))
.setMaxLines(1)
.setFontStyle(FontStyle.Builder().setSize(sp(13f)).build())
.build(),
)
}
}
return Box.Builder()
.setWidth(expand())
.setHeight(expand())
.setVerticalAlignment(VERTICAL_ALIGN_CENTER)
.addContent(column)
.build()
}
private fun topRow(packageName: String): LayoutElement {
val faelle = Text.Builder()
.setText("☰ Fälle")
.setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
.setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
.setVerticalAlignment(VERTICAL_ALIGN_BOTTOM)
.setModifiers(
Modifiers.Builder()
.setClickable(launchClickable(CLICK_ID_LIST, packageName, NavCommand.OPEN_LIST))
.setPadding(
Padding.Builder().setStart(dp(8f)).setEnd(dp(8f))
.setTop(dp(4f)).setBottom(dp(4f)).build(),
)
.build(),
)
.build()
// Row has no horizontal-alignment prop; use a Box with END alignment instead.
return Box.Builder()
.setWidth(expand())
.setHorizontalAlignment(HORIZONTAL_ALIGN_END)
.setVerticalAlignment(VERTICAL_ALIGN_CENTER)
.addContent(faelle)
.addContent(column.build())
.build()
}
private fun middleContent(packageName: String, current: CaseEntry?): LayoutElement {
val label = when {
current == null -> "Noch kein Fall heute"
else -> current.oneliner.displayText()
?.let { if (current.oneliner.isManual()) "👤 $it" else it }
?: ""
}
val textBuilder = Text.Builder()
.setText(label)
.setMaxLines(3)
.setFontStyle(FontStyle.Builder().setSize(sp(16f)).build())
val modifiersBuilder = Modifiers.Builder()
.setPadding(
Padding.Builder().setStart(dp(12f)).setEnd(dp(12f))
.setTop(dp(6f)).setBottom(dp(6f)).build(),
)
if (current != null) {
modifiersBuilder.setClickable(
launchClickable(
CLICK_ID_CONTINUE,
packageName,
NavCommand.OPEN_CONTINUE,
caseId = current.caseId,
),
)
}
textBuilder.setModifiers(modifiersBuilder.build())
return textBuilder.build()
/** "👤 text" if doctor-authored, plain text if auto-generated, "…" placeholder otherwise. */
private fun formatLine(case: CaseEntry): String {
val text = case.oneliner.displayText() ?: return ""
return if (case.oneliner.isManual()) "👤 $text" else text
}
private fun bottomNewButton(packageName: String): LayoutElement {
val text = Text.Builder()
.setText("● Neu")
.setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
.setFontStyle(
FontStyle.Builder()
.setSize(sp(14f))
.setColor(argb(0xFF0A305F.toInt()))
.build(),
)
.build()
return Box.Builder()
.setWidth(expand())
.setHeight(dp(36f))
.setVerticalAlignment(VERTICAL_ALIGN_BOTTOM)
.setHeight(dp(48f))
.setVerticalAlignment(VERTICAL_ALIGN_CENTER)
.setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
.setModifiers(
Modifiers.Builder()
.setClickable(launchClickable(CLICK_ID_NEW, packageName, NavCommand.OPEN_NEW))
.setBackground(
Background.Builder()
.setColor(argb(0xFF1F1F1F.toInt()))
.setCorner(Corner.Builder().setRadius(dp(18f)).build())
.setColor(argb(0xFFAAC7FF.toInt()))
.setCorner(Corner.Builder().setRadius(dp(24f)).build())
.build(),
)
.setPadding(