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
88 changes: 58 additions & 30 deletions Packages/StrandDesign/Sources/StrandDesign/TrendChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public struct TrendChart: View {
public var valueRange: ClosedRange<Double>
/// Whether to draw the soft area fill below the line.
public var showsArea: Bool
/// Draw vertical bars from the axis baseline instead of the line + area + points. One value-ramp-
/// filled `BarMark` per (down-sampled) sample. Display-only — the plotted series is identical; only
/// the mark geometry changes. Default false (the classic line). `showsArea` is ignored in bar mode.
public var showsBars: Bool
public var height: CGFloat
/// Whether hovering reveals a crosshair + tooltip for the nearest point.
public var showsHover: Bool
Expand Down Expand Up @@ -70,6 +74,7 @@ public struct TrendChart: View {
gradient: Gradient = StrandPalette.recoveryGradient,
valueRange: ClosedRange<Double> = 0...100,
showsArea: Bool = true,
showsBars: Bool = false,
height: CGFloat = 220,
showsHover: Bool = true,
valueFormat: @escaping (Double) -> String = { String(Int($0.rounded())) },
Expand All @@ -83,6 +88,7 @@ public struct TrendChart: View {
self.gradient = gradient
self.valueRange = valueRange
self.showsArea = showsArea
self.showsBars = showsBars
self.height = height
self.showsHover = showsHover
self.valueFormat = valueFormat
Expand Down Expand Up @@ -164,52 +170,74 @@ public struct TrendChart: View {

public var body: some View {
Chart {
if showsArea {
if showsBars {
// Bar mode: one value-ramp-filled BarMark per (down-sampled) sample, from the baseline.
// The line, area and point marks are all replaced. The same `displayPoints` feed it, so a
// dense window is min/max-bucketed to the vertex budget exactly as the line is; hover, the
// axes, the domain and accessibility are unchanged (they read the full `points`).
ForEach(displayPoints) { p in
AreaMark(
BarMark(
x: .value("Date", p.date),
y: .value("Value", p.value)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(
LinearGradient(
colors: [
StrandPalette.sample(stops: gradient.toStops(), at: unit(averageValue)).opacity(0.28),
Color.clear
],
startPoint: .top, endPoint: .bottom
.foregroundStyle(valueGradient)
}
} else {
if showsArea {
ForEach(displayPoints) { p in
AreaMark(
x: .value("Date", p.date),
y: .value("Value", p.value)
)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(
LinearGradient(
colors: [
StrandPalette.sample(stops: gradient.toStops(), at: unit(averageValue)).opacity(0.28),
Color.clear
],
startPoint: .top, endPoint: .bottom
)
)
}
}
}
ForEach(displayPoints) { p in
LineMark(
x: .value("Date", p.date),
y: .value("Value", p.value)
)
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
.foregroundStyle(valueGradient)
}
// 18pt dots are invisible on dense series (e.g. a 365-day year) but still cost the
// GPU a mark each — hide them past a threshold; the line carries the data there. The gate
// stays on the full `points.count` (≤60 is never downsampled, so displayPoints == points).
if points.count <= 60 {
ForEach(displayPoints) { p in
PointMark(
LineMark(
x: .value("Date", p.date),
y: .value("Value", p.value)
)
.symbolSize(18)
.foregroundStyle(StrandPalette.sample(stops: gradient.toStops(), at: unit(p.value)))
.interpolationMethod(.catmullRom)
.lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
.foregroundStyle(valueGradient)
}
// 18pt dots are invisible on dense series (e.g. a 365-day year) but still cost the
// GPU a mark each — hide them past a threshold; the line carries the data there. The gate
// stays on the full `points.count` (≤60 is never downsampled, so displayPoints == points).
if points.count <= 60 {
ForEach(displayPoints) { p in
PointMark(
x: .value("Date", p.date),
y: .value("Value", p.value)
)
.symbolSize(18)
.foregroundStyle(StrandPalette.sample(stops: gradient.toStops(), at: unit(p.value)))
}
}
}
}
// Domain drives BOTH the axis extent and the plot clip. A caller that wants a top-of-range
// peak (and the top axis label) to clear the clip passes a `yDomain` whose upper bound sits a
// little above the data — pure data-space headroom, so no macOS14/iOS17 plot-dimension endPadding
// API is needed (#974). The value→color gradient still keys off `valueRange`, unchanged.
.chartYScale(domain: resolvedYDomain)
// Bars must read from a zero baseline to be truthful: a BarMark's length is only proportional to
// its value when 0 is in the domain. The LINE uses a data-FITTED domain (often non-zero — e.g. an
// RHR window of ~48…61) to show variation; reusing that for bars would float every bar near full
// height with the real differences squashed into the top. So in bar mode we drop the floor to 0
// (or below, should a caller ever plot negatives), matching Android's zero-based BarChart. The
// upper bound (with the caller's headroom) is unchanged, so the line's domain is untouched.
.chartYScale(domain: showsBars
? min(0, resolvedYDomain.lowerBound)...resolvedYDomain.upperBound
: resolvedYDomain)
// Clip the plot to its own bounds. catmullRom interpolation overshoots past the data extremes
// on sharp turns, and the AreaMark gradient is drawn UNCLIPPED — so on a spiky HR curve the
// rose fill bled down the page behind the cards below the chart. Clipping the plot area bounds
Expand Down Expand Up @@ -264,7 +292,7 @@ public struct TrendChart: View {
// "Now" end-cap on the latest point (#458). Positioned with the SAME proxy mapping the
// line uses (position(forX:/forY:) + plot origin), so it lands exactly on the curve —
// not via a sibling overlay guessing the axis insets, which floated it left/below.
if let capColor = nowCapColor, let last = points.last,
if !showsBars, let capColor = nowCapColor, let last = points.last,
let px = proxy.position(forX: last.date),
let py = proxy.position(forY: last.value) {
NowCapDot(color: capColor)
Expand Down
19 changes: 19 additions & 0 deletions Strand/Data/Units.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ enum EffortScale: String, CaseIterable, Identifiable {
var id: String { rawValue }
}

/// How the trend charts (Trends tab) are drawn — a purely cosmetic, display-only toggle. The plotted
/// data is identical on both settings; only the mark geometry changes (gradient line + area vs vertical
/// bars). Default is the classic line. Distinct from `ChartStyle`, which chooses the colour ramp; this
/// chooses line-vs-bar. Mirrored on Android by NoopPrefs("trend.chart.style").
enum TrendChartStyle: String, CaseIterable, Identifiable {
/// The classic gradient-stroked line with a soft area fill (the long-standing look).
case line
/// Vertical bars from the axis baseline, one per sample, value-ramp filled.
case bar
var id: String { rawValue }
/// Segmented-control label.
var label: String { self == .bar ? "Bars" : "Line" }
}

/// UserDefaults keys for the two unit preferences. Public-ish (internal) so `SettingsView`'s
/// `@AppStorage(UnitPrefs.systemKey)` and the formatter read the SAME key — no drift.
enum UnitPrefs {
Expand All @@ -50,6 +64,11 @@ enum UnitPrefs {
/// resolves to `.hundred` (NOOP's native axis). Mirrored on Android by NoopPrefs("effort.scale").
static let effortScaleKey = "effort.scale"

/// Trend chart style (line vs bar). Stored raw is a `TrendChartStyle` rawValue; an unset/unknown
/// value resolves to `.line` (the classic look). Display-only — the plotted data never changes.
/// Mirrored on Android by NoopPrefs("trend.chart.style").
static let trendChartStyleKey = "trend.chart.style"

/// Display factor for the #268 Effort scale: the stored 0-100 value multiplied by this renders on
/// the user's chosen axis (1.0 for the native 0-100, 0.21 for the WHOOP-style 0-21). Display-only,
/// mirrors Android's effortDisplayFactor helper so digest sentences match the charts on both.
Expand Down
15 changes: 15 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ struct SettingsView: View {
// Effort display scale (#268). Display-only — Effort stays stored 0–100, this only chooses whether
// it's shown on NOOP's 0–100 axis or WHOOP's 0–21 Day Strain axis.
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
@AppStorage(UnitPrefs.trendChartStyleKey) private var trendChartStyleRaw = TrendChartStyle.line.rawValue
// Live-HR Live Activity (Lock Screen + Dynamic Island), iOS only (#336). Default on.
@AppStorage(UnitPrefs.liveActivityKey) private var liveActivityEnabled = true
// Alternate app icon (iOS only) — false = Titanium (primary AppIcon), true = Blue Titanium
Expand Down Expand Up @@ -670,6 +671,20 @@ struct SettingsView: View {
.fixedSize()
.accessibilityLabel("Chart colours")
}
rowDivider
// Trend chart style (line vs bar). Display-only: flips the Trends tab's charts between the
// gradient line + area and value-ramp bars. The plotted data is identical either way.
FormRow(label: "Trend charts") {
Picker("Trend charts", selection: $trendChartStyleRaw) {
ForEach(TrendChartStyle.allCases) { style in
Text(style.label).tag(style.rawValue)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.fixedSize()
.accessibilityLabel("Trend chart style")
}
#if os(iOS)
rowDivider // #79: separator before App icon (inside #if so macOS keeps a single divider)
FormRow(label: "App icon") {
Expand Down
7 changes: 6 additions & 1 deletion Strand/Screens/TrendsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ struct TrendsView: View {

// Effort display scale (#268) — routes the Effort small-multiple's numbers + unit. Display-only.
@AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue
// Trend chart style (line vs bar) — display-only; flips every trend card between the gradient line
// and value-ramp bars. Read here at the screen root so a Settings change re-renders on return.
@AppStorage(UnitPrefs.trendChartStyleKey) private var trendChartStyleRaw = TrendChartStyle.line.rawValue
private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) }

// yyyy-MM-dd → Date (en_US_POSIX, UTC), per task spec.
Expand Down Expand Up @@ -755,7 +758,9 @@ struct TrendsView: View {
// scales and lands on the line — the previous sibling overlay guessed the plot insets and
// floated the dot left/below the curve (#458).
TrendChart(points: pts, gradient: gradient, valueRange: valueRange,
showsArea: true, height: NoopMetrics.chartHeight, valueFormat: valueFormat,
showsArea: true,
showsBars: TrendChartStyle(rawValue: trendChartStyleRaw) == .bar,
height: NoopMetrics.chartHeight, valueFormat: valueFormat,
accessibilityLabel: accessibilityLabel, nowCapColor: tip)
}

Expand Down
17 changes: 17 additions & 0 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,9 @@ fun SettingsScreen(
var themeMode by remember { mutableStateOf(AppearancePrefs.mode) }
// Chart colours (Titanium / Classic) — re-colours gauges + charts; ChartStylePrefs mirrors it live.
var chartStyle by remember { mutableStateOf(ChartStylePrefs.style) }
// Trend charts (Line / Bar) — flips the Trends tab between the gradient line and value-ramp bars.
// Display-only; SharedPreferences isn't reactive, so mirror into local state and persist on select.
var trendChartStyle by remember { mutableStateOf(UnitPrefs.trendChartStyle(context)) }
// Day-cycle background (#698) — the time-of-day scene behind Today. Default ON. SharedPreferences
// isn't reactive, so the Switch mirrors into local state; TodayScreen reads the same pref on entry.
var showDayCycleBackground by remember { mutableStateOf(NoopPrefs.showDayCycleBackground(context)) }
Expand Down Expand Up @@ -924,6 +927,20 @@ fun SettingsScreen(
},
)
}
RowDivider()
// Trend chart style (line vs bar). Display-only: flips the Trends tab's charts between the
// gradient line and value-ramp bars. The plotted data is identical either way.
FormRow(label = "Trend charts") {
SegmentedPillControl(
items = listOf(TrendChartStyle.LINE, TrendChartStyle.BAR),
selection = trendChartStyle,
label = { if (it == TrendChartStyle.BAR) "Bars" else "Line" },
onSelect = { style ->
trendChartStyle = style
UnitPrefs.setTrendChartStyle(context, style)
},
)
}

// Day-cycle background (#698): the time-of-day scene behind Today. On by default. Off swaps it
// for a plain dark canvas for people who find the moving scene distracting. Takes effect next
Expand Down
39 changes: 28 additions & 11 deletions android/app/src/main/java/com/noop/ui/TrendsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,10 @@ private fun ChartWithAxes(
val maxV = values.max()
val avgV = values.average()
val minV = values.min()
// Trend chart style (line vs bar). Read here at the single chart choke point (every trend card routes
// through ChartWithAxes); SharedPreferences isn't reactive, but returning from Settings recomposes the
// Trends screen, which re-reads it — the same read-on-recompose the Effort scale toggle relies on.
val chartStyle = UnitPrefs.trendChartStyle(LocalContext.current)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.height(IntrinsicSize.Min),
Expand Down Expand Up @@ -792,17 +796,30 @@ private fun ChartWithAxes(
contentAlignment = Alignment.BottomCenter,
) {
Box(modifier = Modifier.fillMaxWidth().height(plotHeight)) {
LineChart(
values = values,
modifier = Modifier.fillMaxSize(),
color = color,
fill = true,
selectionEnabled = true,
// #463: the pinpoint label goes through the SAME formatter as the axis column,
// so a tapped Effort day can't print the stored 0-100 value beside a 0-21 axis.
formatValue = formatY,
)
GlowEndCap(values = values, tipColor = tipColor)
if (chartStyle == TrendChartStyle.BAR) {
// Bar mode: value-ramp bars from the baseline. No GlowEndCap (the "now" halo is a
// line idiom). selectionEnabled is OFF so BarChart mean-bins a dense window (the
// multi-year "ALL" span) down to the pixel width — a clean silhouette instead of a
// 1000-bar sub-pixel smear. The max/avg/min axis column + footer carry the numbers.
BarChart(
values = values,
modifier = Modifier.fillMaxSize(),
color = color,
selectionEnabled = false,
)
} else {
LineChart(
values = values,
modifier = Modifier.fillMaxSize(),
color = color,
fill = true,
selectionEnabled = true,
// #463: the pinpoint label goes through the SAME formatter as the axis column,
// so a tapped Effort day can't print the stored 0-100 value beside a 0-21 axis.
formatValue = formatY,
)
GlowEndCap(values = values, tipColor = tipColor)
}
}
}
}
Expand Down
31 changes: 31 additions & 0 deletions android/app/src/main/java/com/noop/ui/Units.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ enum class EffortScale(val raw: String) {
}
}

/**
* How the Trends charts are drawn — line vs bar. A purely cosmetic, display-only toggle: the plotted
* data is identical on both settings, only the mark geometry changes. Default is the classic line.
* Distinct from [ChartStyle] (which picks the colour ramp); this picks the shape. Mirrors the macOS
* [TrendChartStyle].
*/
enum class TrendChartStyle(val raw: String) {
/** The classic gradient-stroked line with a soft area fill (the long-standing look). */
LINE("line"),

/** Vertical bars from the axis baseline, one per sample. */
BAR("bar");

companion object {
/** An unset/unknown value resolves to the classic line. */
fun fromRaw(raw: String?): TrendChartStyle = entries.firstOrNull { it.raw == raw } ?: LINE
}
}

/**
* Reads the two unit preferences from [NoopPrefs] and resolves the "match the system" default for
* temperature. SharedPreferences isn't reactive, so Compose screens read these once into remembered
Expand Down Expand Up @@ -91,6 +110,18 @@ object UnitPrefs {
fun setEffortScale(context: Context, scale: EffortScale) {
NoopPrefs.of(context).edit().putString(KEY_EFFORT_SCALE, scale.raw).apply()
}

/** SharedPreferences key for the Trends chart style. Mirrors macOS @AppStorage("trend.chart.style"). */
const val KEY_TREND_CHART_STYLE = "trend.chart.style"

/** The Trends chart style (default line). Read once into Compose state like the other prefs. */
fun trendChartStyle(context: Context): TrendChartStyle =
TrendChartStyle.fromRaw(NoopPrefs.of(context).getString(KEY_TREND_CHART_STYLE, null))

/** Persist the Trends chart style. */
fun setTrendChartStyle(context: Context, style: TrendChartStyle) {
NoopPrefs.of(context).edit().putString(KEY_TREND_CHART_STYLE, style.raw).apply()
}
}

/**
Expand Down