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
45 changes: 37 additions & 8 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ struct TodayView: View {
// 14-day sparkline series, keyed by metric key. Loaded once in .task.
@State private var sparks: [String: [Double]] = [:]
@State private var workouts: [WorkoutRow] = []
/// #1694: a tapped Latest-Workouts tile. Wrapped so `.sheet(item:)` drives presentation, mirroring
/// WorkoutsView's own detail target — the feed was read-only, so the only route to a session's
/// detail was More > Workouts.
private struct WorkoutDetailTarget: Identifiable {
let row: WorkoutRow
let id = UUID()
}
@State private var workoutDetail: WorkoutDetailTarget?
@State private var appleDays: [AppleDaily] = []
// Design Reset / #582, the pinned "Your cards" values (Stress / Fitness age / Vitality), surfaced
// on Today so the buried Explore features sit on the home screen. Loaded in loadAll; nil hides the row.
Expand Down Expand Up @@ -1511,6 +1519,20 @@ struct TodayView: View {
hostedCardsRaw: $hostedCardsRaw
)
}
// #1694: the same read-only WorkoutDetailView the Workouts list opens. Nothing here can edit or
// delete, so a tap from Today carries no risk that list does not already carry. Rides its own
// NavigationStack because these shared screens are not in a per-screen one — mirrors WorkoutsView.
.sheet(item: $workoutDetail) { target in
NavigationStack {
WorkoutDetailView(row: target.row)
.environmentObject(repo)
}
#if os(iOS)
.noopSheetPresentation(largeFirst: true)
#else
.frame(width: 620, height: 720)
#endif
}
#if os(iOS)
.fullScreenCover(isPresented: $showLiveSession) {
LiveSessionView(onClose: { showLiveSession = false })
Expand Down Expand Up @@ -3853,14 +3875,21 @@ struct TodayView: View {
trailing: String(localized: "\(workouts.count) total"))
LazyVGrid(columns: grid, alignment: .leading, spacing: NoopMetrics.gap) {
ForEach(Array(workouts.prefix(6).enumerated()), id: \.offset) { _, w in
StatTile(
label: "\(WorkoutSource.displaySport(w.sport))",
value: workoutDuration(w),
caption: workoutCaption(w),
accent: StrandPalette.effortTint(fraction: (w.strain ?? 0) / StrainScorer.maxStrain),
delta: w.energyKcal.map { "\(Int($0.rounded())) kcal" },
deltaColor: StrandPalette.metricAmber
)
Button {
workoutDetail = WorkoutDetailTarget(row: w)
} label: {
StatTile(
label: "\(WorkoutSource.displaySport(w.sport))",
value: workoutDuration(w),
caption: workoutCaption(w),
accent: StrandPalette.effortTint(fraction: (w.strain ?? 0) / StrainScorer.maxStrain),
delta: w.energyKcal.map { "\(Int($0.rounded())) kcal" },
deltaColor: StrandPalette.metricAmber
)
}
// The Workouts list's own rows use this, not .plain: it is the iOS twin of
// Android's liquidPress, so the tile settles inward on press on both platforms.
.buttonStyle(LiquidPressStyle())
}
}
}
Expand Down
26 changes: 23 additions & 3 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,9 @@ fun TodayScreen(
// existing RecoveryDriversSection (gated to the calibration countdown when the night can't score) plus
// the folded Readiness card (S4). Not persisted, so it reopens closed. Mirrors iOS showChargeBreakdown.
var showChargeBreakdown by remember { mutableStateOf(false) }
// #1694: the Latest-Workouts tile that was tapped. Held HERE, not inside the section: the Today
// sections are LazyColumn items, and a disposed item would take an open sheet down with it.
var selectedWorkoutRow by remember { mutableStateOf<WorkoutRow?>(null) }
// LIVE SESSIONS (beta, default ON): the "Start session" entry under the hero + its full-screen Dialog
// (the same presentation the live-workout overlay / Charge breakdown use — deliberately NOT a nav
// destination, so dismissing it leaves the session's runner coaching and this entry is the way back
Expand Down Expand Up @@ -1582,7 +1585,7 @@ fun TodayScreen(
modifier = Modifier.fillMaxWidth().staggeredAppear(stagger),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TodayWorkoutsSection(footer.recentWorkouts)
TodayWorkoutsSection(footer.recentWorkouts, onSelect = { selectedWorkoutRow = it })
}
// HEART RATE, the live HR thread / trend card. #991: header + card in a Column.
TodaySection.HEART_RATE -> Column(
Expand Down Expand Up @@ -1741,6 +1744,11 @@ fun TodayScreen(
}
}

// #1694: the tapped Latest-Workouts session, in the same read-only sheet the Workouts list opens.
selectedWorkoutRow?.let { row ->
WorkoutDetailSheet(vm = viewModel, row = row, onDismiss = { selectedWorkoutRow = null })
}

// LIVE SESSIONS (beta): the full-screen session dialog — the same presentation the live-workout
// overlay uses on Live (Dialog, usePlatformDefaultWidth = false). Dismissing it only HIDES the
// screen: the runner (held in LiveSessionRunner.active, ticking on the app-wide viewModelScope)
Expand Down Expand Up @@ -6213,7 +6221,7 @@ private fun WorkoutGlyph(icon: ImageVector, modifier: Modifier = Modifier) {
// MARK: - Today footer sections

@Composable
private fun TodayWorkoutsSection(workouts: List<WorkoutRow>) {
private fun TodayWorkoutsSection(workouts: List<WorkoutRow>, onSelect: (WorkoutRow) -> Unit) {
// Single column, newest first: the 2x2 grid truncated durations on narrow phones and read as
// unrelated stat tiles rather than a chronological feed. Full-width tiles have room for the
// kcal chip, so the #332 compactDelta workaround is no longer needed here.
Expand All @@ -6223,10 +6231,22 @@ private fun TodayWorkoutsSection(workouts: List<WorkoutRow>) {
// "Latest Workouts", not "Last": "Last" read as "final". Mirrored on iOS (TodayView). Lives in
// strings.xml (values + values-de) so the header is localizable like the nav labels.
SectionHeader(stringResource(R.string.today_latest_workouts), overline = uiString(R.string.today_workouts_activity), trailing = uiString(R.string.today_workouts_14_days))
// #1694: the feed was read-only, so the only way to a session's detail was More > Workouts. The
// tap opens the SAME read-only WorkoutDetailSheet the Workouts list uses — nothing there can edit
// or delete, so a tap from Today carries no risk that list does not already carry.
Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) {
feed.forEach { workout ->
val interaction = remember(workout.startTs, workout.source) { MutableInteractionSource() }
StatTile(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.liquidPress(interaction)
.clickable(
interactionSource = interaction,
indication = null,
onClickLabel = uiString(R.string.today_action_show_workout),
onClick = { onSelect(workout) },
),
label = WorkoutEditing.displaySport(workout.sport),
value = localizedMetricValue(workoutDuration(workout)),
caption = workoutCaption(workout),
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1483,7 +1483,7 @@ private fun SessionRow(

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WorkoutDetailSheet(vm: AppViewModel, row: WorkoutRow, onDismiss: () -> Unit) {
internal fun WorkoutDetailSheet(vm: AppViewModel, row: WorkoutRow, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)

// Per-window reads (#410): the HR curve (downsampled bucket means) and the HR-zone split. Zones
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2271,6 +2271,7 @@
<string name="today_action_reset_hr_zoom">Herzfrequenz-Zoom zurücksetzen</string>
<string name="today_action_see_what_shaped">Einflüsse auf %1$s anzeigen</string>
<string name="today_action_show_sources">Synchronisierte NOOP-Quellen anzeigen</string>
<string name="today_action_show_workout">Workout-Details anzeigen</string>
<string name="today_contributors">Einflussfaktoren</string>
<string name="today_data_sources">Datenquellen</string>
<string name="today_hr_beats_per_minute">Schläge pro Minute</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2257,6 +2257,7 @@
<string name="today_action_reset_hr_zoom">Restablecer zoom de frecuencia cardíaca</string>
<string name="today_action_see_what_shaped">Ver qué influyó en %1$s</string>
<string name="today_action_show_sources">Mostrar las fuentes sincronizadas con NOOP</string>
<string name="today_action_show_workout">Mostrar los detalles del entrenamiento</string>
<string name="today_contributors">Factores</string>
<string name="today_data_sources">Fuentes de datos</string>
<string name="today_hr_beats_per_minute">Latidos por minuto</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2256,6 +2256,7 @@
<string name="today_action_reset_hr_zoom">Réinitialiser le zoom de fréquence cardiaque</string>
<string name="today_action_see_what_shaped">Voir ce qui a influencé %1$s</string>
<string name="today_action_show_sources">Afficher les sources synchronisées avec NOOP</string>
<string name="today_action_show_workout">Afficher les détails de la séance</string>
<string name="today_contributors">Facteurs</string>
<string name="today_data_sources">Sources de données</string>
<string name="today_hr_beats_per_minute">Battements par minute</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2250,6 +2250,7 @@
<string name="today_action_reset_hr_zoom">Zresetuj powiększenie tętna</string>
<string name="today_action_see_what_shaped">Zobacz, co wpłynęło na %1$s</string>
<string name="today_action_show_sources">Pokaż źródła synchronizowane z NOOP</string>
<string name="today_action_show_workout">Pokaż szczegóły treningu</string>
<string name="today_contributors">Czynniki</string>
<string name="today_data_sources">Źródła danych</string>
<string name="today_hr_beats_per_minute">Uderzenia na minutę</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-pt-rPT/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2250,6 +2250,7 @@
<string name="today_action_reset_hr_zoom">Repor zoom da frequência cardíaca</string>
<string name="today_action_see_what_shaped">Ver o que influenciou %1$s</string>
<string name="today_action_show_sources">Mostrar as fontes sincronizadas com o NOOP</string>
<string name="today_action_show_workout">Mostrar os detalhes do treino</string>
<string name="today_contributors">Contributos</string>
<string name="today_data_sources">Fontes de dados</string>
<string name="today_hr_beats_per_minute">Batimentos por minuto</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2185,6 +2185,7 @@
<string name="today_action_reset_hr_zoom">重置心率缩放</string>
<string name="today_action_see_what_shaped">查看影响 %1$s 的因素</string>
<string name="today_action_show_sources">查看 NOOP 同步的数据源</string>
<string name="today_action_show_workout">查看锻炼详情</string>
<string name="today_contributors">影响因素</string>
<string name="today_data_sources">数据源</string>
<string name="today_hr_beats_per_minute">每分钟心跳</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2370,6 +2370,7 @@
<string name="today_action_reset_hr_zoom">Reset the heart rate zoom</string>
<string name="today_action_see_what_shaped">See what shaped your %1$s</string>
<string name="today_action_show_sources">Show what NOOP is synced from</string>
<string name="today_action_show_workout">Show workout detail</string>
<string name="today_contributors">Contributors</string>
<string name="today_data_sources">Data sources</string>
<string name="today_hr_beats_per_minute">Beats per minute</string>
Expand Down
81 changes: 81 additions & 0 deletions android/app/src/test/java/com/noop/ui/TodayWorkoutTapTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.noop.ui

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File

/**
* #1694: the Today "Latest Workouts" tiles open the SAME read-only detail the Workouts list opens.
*
* Compose wiring has no logic function to assert against, so this reads the sources — the idiom
* [ResolvedSeriesCallSiteAuditTest] already uses. What it pins is not cosmetic:
*
* The Today sections are LazyColumn items. Hosting the sheet inside [TodayWorkoutsSection] compiles and
* looks correct, but a disposed item takes an open sheet down with it, so the sheet MUST stay at screen
* level — which is also where every other Today dialog lives. That mistake is invisible to a compiler
* and to every other test in this suite.
*/
class TodayWorkoutTapTest {

private fun repoRoot(): File {
val userDir = File(System.getProperty("user.dir") ?: ".")
val candidates = listOf(userDir, File(userDir, ".."), File(userDir, "../.."))
return candidates.firstOrNull { File(it, "Strand/Screens/TodayView.swift").isFile }
?: error("could not locate the repo root from ${userDir.absolutePath}")
}

private fun todayScreenKt() = File(repoRoot(), "android/app/src/main/java/com/noop/ui/TodayScreen.kt").readText()

/**
* The section body, from its declaration to the start of the next top-level composable, with `//`
* comments removed. Stripping matters: this file's comments NAME the very symbols asserted on, so a
* raw substring search matches the prose describing the code rather than the code.
*/
private fun workoutsSectionBody(source: String): String {
val start = source.indexOf("private fun TodayWorkoutsSection(")
assertTrue("TodayWorkoutsSection not found", start > 0)
val next = source.indexOf("\n@Composable", start)
val body = source.substring(start, if (next > start) next else source.length)
return body.lines().joinToString("\n") { it.substringBefore("//") }
}

@Test
fun tilesAreClickableAndReportTheTappedRow() {
val body = workoutsSectionBody(todayScreenKt())
assertTrue("the feed tiles must be clickable", body.contains(".clickable("))
assertTrue("a tap must report the row it was on", body.contains("onSelect(workout)"))
assertTrue(
"the tap needs an accessibility action label, like the Sources row beside it",
body.contains("R.string.today_action_show_workout"),
)
}

@Test
fun theSheetIsHostedAtScreenLevelNotInsideTheLazySection() {
val source = todayScreenKt()
assertTrue(
"Today must open the Workouts list's own read-only sheet",
source.contains("WorkoutDetailSheet(vm = viewModel, row = row"),
)
assertFalse(
"hosting the sheet inside the section loses it when the LazyColumn disposes that item",
workoutsSectionBody(source).contains("WorkoutDetailSheet("),
)
}

/** Parity: iOS reaches the same read-only detail from the same feed, and presents it at screen level. */
@Test
fun iosTodayOpensTheSameDetailComponent() {
val swift = File(repoRoot(), "Strand/Screens/TodayView.swift").readText()
assertTrue("iOS tiles must open a detail", swift.contains("workoutDetail = WorkoutDetailTarget(row: w)"))
assertTrue(
"iOS must present the SAME read-only view the Workouts list uses",
swift.contains("WorkoutDetailView(row: target.row)"),
)
assertTrue(
"the press treatment must match Android's liquidPress, not fall back to .plain",
swift.contains("LiquidPressStyle()"),
)
}
}
Loading