diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bd134..e4d7e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0c1624..c381adc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt index 9623548..b52fb61 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt @@ -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 @@ -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(null) } var pendingArchive by rememberSaveable { mutableStateOf(null) } var pendingEditAppearance by rememberSaveable { mutableStateOf(null) } @@ -135,6 +137,12 @@ fun ManageCategoriesScreen( } } + // ── Help dialog ─────────────────────────────────────────────────────────── + + if (showHelp) { + CategoriesHelpDialog(onDismiss = { showHelp = false }) + } + // ── Add category dialog ─────────────────────────────────────────────────── if (showAddDialog) { @@ -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 @@ -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) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt index 39ac2ef..cbe5997 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt @@ -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, ) } } @@ -177,6 +183,10 @@ 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, @@ -184,7 +194,12 @@ internal suspend fun computeChartData( 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, + ) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt index 75713f5..c84198b 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt @@ -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 @@ -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) { @@ -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) @@ -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(), @@ -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), ) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt index 3a1acda..aed50a8 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt @@ -89,7 +89,13 @@ data class PinnedStat( sealed class StatsChartData { data object Empty : StatsChartData() data object Loading : StatsChartData() - data class PieData(val slices: List) : StatsChartData() + data class PieData( + val slices: List, + /** 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 = emptyMap(), + ) : StatsChartData() data class TimeSeriesData(val buckets: List, val categoryName: String) : StatsChartData() data class ComboData(val bars: List) : StatsChartData() data class DualTimeSeriesData( @@ -129,6 +135,10 @@ sealed class StatsChartData { data class TrendsData( val bars: List, 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 = emptyMap(), ) : StatsChartData() data class PhaseSummaryData( val rows: List, @@ -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, ) } } @@ -666,6 +682,10 @@ 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, @@ -673,7 +693,12 @@ class StatsViewModel( 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, + ) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt b/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt index 823f8c0..0a2e5b4 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/util/CategoryAppearance.kt @@ -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 @@ -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