Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ Rules:
- Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward

---
## [0.29.0-beta.1] - 2026-06-03

### Added
- Manage: Info button in the Tracking Categories toolbar opens a help dialog explaining category types, value ordering, drag-to-reorder, archive, and delete.

---

## [0.28.0-beta.1] - 2026-06-03

### Added
- Stats: Pie chart slices and Trends progress bars now use ordered shades of the category's own colour for default-type categories. Lower-order values (e.g. Spotting) appear as a lighter tint; higher-order values (e.g. Heavy) appear as the full colour. Colour still varies by hue for numeric and non-default categories.

---

## [0.27.0-beta.1] - 2026-06-03

### Added
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ android {
applicationId = "com.mapgie.goflo"
minSdk = 26
targetSdk = 34
versionCode = 67
versionName = "0.27.0-beta.1"
versionCode = 69
versionName = "0.29.0-beta.1"
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
Expand Down Expand Up @@ -119,6 +120,7 @@ fun ManageCategoriesScreen(
val state by viewModel.uiState.collectAsState()

var showAddDialog by rememberSaveable { mutableStateOf(false) }
var showHelp by rememberSaveable { mutableStateOf(false) }
var pendingDelete by rememberSaveable { mutableStateOf<Long?>(null) }
var pendingArchive by rememberSaveable { mutableStateOf<Long?>(null) }
var pendingEditAppearance by rememberSaveable { mutableStateOf<Long?>(null) }
Expand All @@ -135,6 +137,12 @@ fun ManageCategoriesScreen(
}
}

// ── Help dialog ───────────────────────────────────────────────────────────

if (showHelp) {
CategoriesHelpDialog(onDismiss = { showHelp = false })
}

// ── Add category dialog ───────────────────────────────────────────────────

if (showAddDialog) {
Expand Down Expand Up @@ -278,6 +286,15 @@ fun ManageCategoriesScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showHelp = true }) {
Icon(
Icons.Outlined.Info,
contentDescription = "Help",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
Expand Down Expand Up @@ -435,6 +452,70 @@ fun ManageCategoriesScreen(
}
}

// ── Help dialog ───────────────────────────────────────────────────────────────

@Composable
private fun CategoriesHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Using Categories") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"Categories are the topics you track alongside your cycle: mood, sleep, medications, symptoms, and more. Flow and Symptoms are built in. Everything else you create yourself.",
style = MaterialTheme.typography.bodyMedium
)

HelpSection(
title = "Category types",
body = "Default: choose from a list of named values you define. Good for things like mood or discharge type.\n\n" +
"Slider scale: pick a number on a fixed scale. Useful for pain, energy, or any rated feeling.\n\n" +
"Numeric: log a specific number each day. Works well for temperature or heart rate.\n\n" +
"Plus One: tap to count one more. Handy for coffees, glasses of water, or medication doses."
)

HelpSection(
title = "Values and order",
body = "For Default categories, tap the category to add or rename its values. The order of values in that list affects how they appear in Stats: the first value shows as the lightest shade of the category colour, the last as the deepest."
)

HelpSection(
title = "Reorder categories",
body = "Long-press the drag handle on the right side of a row and drag it to a new position."
)

HelpSection(
title = "Archive",
body = "Archiving hides a category from the logging screen without removing any of your data. Swipe right on a category to archive it. Scroll to the bottom of this screen and tap Archived to restore it."
)

HelpSection(
title = "Delete",
body = "Swipe left on a custom category to delete it. Deleting permanently removes the category and all its logged data. This cannot be undone."
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Got it") }
}
)
}

@Composable
private fun HelpSection(title: String, body: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(
body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

// ── Category row ──────────────────────────────────────────────────────────────

@OptIn(ExperimentalMaterial3Api::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ internal suspend fun computeChartData(
StatsChartData.Empty
} else {
val total = counts.sumOf { it.count }.coerceAtLeast(1)
val valueOrders = if (category1.categoryType == "default") {
repository.getValuesForCategoryOnce(category1.id)
.associate { it.label to it.displayOrder }
} else emptyMap()
StatsChartData.PieData(
counts.map { PieSlice(it.valueLabel, it.count, it.count.toFloat() / total) }
slices = counts.map { PieSlice(it.valueLabel, it.count, it.count.toFloat() / total) },
colorToken = if (category1.categoryType == "default") category1.colorToken else "",
valueOrders = valueOrders,
)
}
}
Expand Down Expand Up @@ -177,14 +183,23 @@ internal suspend fun computeChartData(
if (counts.isEmpty()) StatsChartData.Empty
else {
val total = counts.sumOf { it.count }.coerceAtLeast(1)
val valueOrders = if (category1.categoryType == "default") {
repository.getValuesForCategoryOnce(category1.id)
.associate { it.label to it.displayOrder }
} else emptyMap()
val bars = counts.sortedByDescending { it.count }.take(10).map { vc ->
TrendsBar(
label = vc.valueLabel,
count = vc.count,
percentage = (vc.count * 100 / total).coerceAtMost(100)
)
}
StatsChartData.TrendsData(bars, category1.name)
StatsChartData.TrendsData(
bars = bars,
categoryName = category1.name,
colorToken = if (category1.categoryType == "default") category1.colorToken else "",
valueOrders = valueOrders,
)
}
}

Expand Down
33 changes: 30 additions & 3 deletions app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.mapgie.goflo.ui.util.ordinalShade
import com.mapgie.goflo.ui.util.toCategoryColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
Expand Down Expand Up @@ -57,7 +58,18 @@ fun PieChart(
data: StatsChartData.PieData,
modifier: Modifier = Modifier
) {
val colors = data.slices.mapIndexed { i, _ -> chartColor(i) }
val surface = MaterialTheme.colorScheme.surface
val baseOrdinalColor = if (data.colorToken.isNotEmpty() && data.valueOrders.isNotEmpty()) {
data.colorToken.toCategoryColor()
} else null
val orderedLabels = data.valueOrders.entries.sortedBy { it.value }.map { it.key }
val colors = data.slices.mapIndexed { i, slice ->
if (baseOrdinalColor != null) {
val pos = orderedLabels.indexOf(slice.label)
if (pos >= 0) ordinalShade(baseOrdinalColor, surface, pos, orderedLabels.size)
else chartColor(i)
} else chartColor(i)
}
val holeColor = MaterialTheme.colorScheme.surfaceVariant

Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Expand Down Expand Up @@ -671,6 +683,20 @@ fun TimeScatterChart(data: StatsChartData.TimeScatterData, modifier: Modifier =

@Composable
fun TrendsChart(data: StatsChartData.TrendsData) {
val surface = MaterialTheme.colorScheme.surface
val primary = MaterialTheme.colorScheme.primary
val baseOrdinalColor = if (data.colorToken.isNotEmpty() && data.valueOrders.isNotEmpty()) {
data.colorToken.toCategoryColor()
} else null
val orderedLabels = data.valueOrders.entries.sortedBy { it.value }.map { it.key }

fun barColor(label: String): Color {
if (baseOrdinalColor == null) return primary
val pos = orderedLabels.indexOf(label)
return if (pos >= 0) ordinalShade(baseOrdinalColor, surface, pos, orderedLabels.size)
else primary
}

Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
Expand All @@ -681,6 +707,7 @@ fun TrendsChart(data: StatsChartData.TrendsData) {
color = MaterialTheme.colorScheme.onSurfaceVariant
)
data.bars.forEach { bar ->
val color = barColor(bar.label)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
Modifier.fillMaxWidth(),
Expand All @@ -696,8 +723,8 @@ fun TrendsChart(data: StatsChartData.TrendsData) {
LinearProgressIndicator(
progress = { bar.percentage / 100f },
modifier = Modifier.fillMaxWidth().height(4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
color = color,
trackColor = color.copy(alpha = 0.15f),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ data class PinnedStat(
sealed class StatsChartData {
data object Empty : StatsChartData()
data object Loading : StatsChartData()
data class PieData(val slices: List<PieSlice>) : StatsChartData()
data class PieData(
val slices: List<PieSlice>,
/** Category color token used to shade slices by displayOrder. Empty = cycle colours. */
val colorToken: String = "",
/** Maps each value label to its displayOrder for ordinal shade computation. */
val valueOrders: Map<String, Int> = emptyMap(),
) : StatsChartData()
data class TimeSeriesData(val buckets: List<TimeBucket>, val categoryName: String) : StatsChartData()
data class ComboData(val bars: List<ComboBar>) : StatsChartData()
data class DualTimeSeriesData(
Expand Down Expand Up @@ -129,6 +135,10 @@ sealed class StatsChartData {
data class TrendsData(
val bars: List<TrendsBar>,
val categoryName: String,
/** Category color token used to shade bars by displayOrder. Empty = use primary. */
val colorToken: String = "",
/** Maps each value label to its displayOrder for ordinal shade computation. */
val valueOrders: Map<String, Int> = emptyMap(),
) : StatsChartData()
data class PhaseSummaryData(
val rows: List<PhaseSummaryRow>,
Expand Down Expand Up @@ -530,8 +540,14 @@ class StatsViewModel(
StatsChartData.Empty
} else {
val total = counts.sumOf { it.count }.coerceAtLeast(1)
val valueOrders = if (cat1.categoryType == "default") {
repository.getValuesForCategoryOnce(cat1.id)
.associate { it.label to it.displayOrder }
} else emptyMap()
StatsChartData.PieData(
counts.map { PieSlice(it.valueLabel, it.count, it.count.toFloat() / total) }
slices = counts.map { PieSlice(it.valueLabel, it.count, it.count.toFloat() / total) },
colorToken = if (cat1.categoryType == "default") cat1.colorToken else "",
valueOrders = valueOrders,
)
}
}
Expand Down Expand Up @@ -666,14 +682,23 @@ class StatsViewModel(
if (counts.isEmpty()) StatsChartData.Empty
else {
val total = counts.sumOf { it.count }.coerceAtLeast(1)
val valueOrders = if (cat1.categoryType == "default") {
repository.getValuesForCategoryOnce(cat1.id)
.associate { it.label to it.displayOrder }
} else emptyMap()
val bars = counts.sortedByDescending { it.count }.take(10).map { vc ->
TrendsBar(
label = vc.valueLabel,
count = vc.count,
percentage = (vc.count * 100 / total).coerceAtMost(100)
)
}
StatsChartData.TrendsData(bars, cat1.name)
StatsChartData.TrendsData(
bars = bars,
categoryName = cat1.name,
colorToken = if (cat1.categoryType == "default") cat1.colorToken else "",
valueOrders = valueOrders,
)
}
}

Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector

Expand Down Expand Up @@ -192,6 +193,20 @@ fun String.toCategoryColor(): Color {
* For custom hex colours, luminance is checked: light backgrounds get a
* near-black tint; dark backgrounds get white.
*/
/**
* Returns a shade of [base] for the [index]-th value in an ordinal sequence of
* [total] values, blending toward [surface] for lower-order (lighter) values.
*
* index 0 → lightest (30% base, 70% surface); index total-1 → full [base].
* With a single value, [base] is returned unchanged.
*/
fun ordinalShade(base: Color, surface: Color, index: Int, total: Int): Color {
if (total <= 1) return base
val fraction = index.toFloat() / (total - 1).toFloat()
val t = 0.30f + 0.70f * fraction
return lerp(surface, base, t)
}

@Composable
fun String.toCategoryOnColor(): Color {
val s = MaterialTheme.colorScheme
Expand Down