diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13099e27cf..a07ae7c21d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ compass = "1.2.4" ksensor = "2.0.4" kmp-zip = "0.8.0" vico = "3.1.0" +skiko = "0.8.18" # aligned with compose [libraries] multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" } @@ -49,6 +50,7 @@ ksensor = { module = "io.github.shadmanadman:KSensor", version.ref = "ksensor" } kmp-zip = { module = "no.synth:kmp-zip", version.ref = "kmp-zip" } vico-compose = { module = "com.patrykandpatrick.vico:compose", version.ref = "vico" } vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" } +skiko = { module = "org.jetbrains.skiko:skiko", version.ref = "skiko" } [plugins] app-cash-sqldelight = { id = "app.cash.sqldelight", version = "2.1.0" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f405f6b84c..51f83db492 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -457,6 +457,7 @@ kotlin { // KMP dependencies declared in commonMain. implementation(libs.ktor.client.darwin) implementation("app.cash.sqldelight:native-driver:2.1.0") + implementation(libs.skiko) } } } diff --git a/shared/docs/kmp-migration-checklist.md b/shared/docs/kmp-migration-checklist.md index 34a2909177..3903f84e37 100644 --- a/shared/docs/kmp-migration-checklist.md +++ b/shared/docs/kmp-migration-checklist.md @@ -187,6 +187,12 @@ | Media keycode events | | | | | | | | **Statistics** | | | +| Total | :white_check_mark: | :white_check_mark: | +| Year | :white_check_mark: | :white_check_mark: | +| Month | :white_check_mark: | :white_check_mark: | +| Share image | :white_check_mark: | | +| Heatmap | :white_check_mark: | :white_check_mark: | +| Heatmap/DatePicker | :white_check_mark: | | | | | | | ... | | | | | | | diff --git a/shared/src/androidMain/kotlin/com/fieldbook/shared/screens/statistics/ImageEncoder.android.kt b/shared/src/androidMain/kotlin/com/fieldbook/shared/screens/statistics/ImageEncoder.android.kt new file mode 100644 index 0000000000..6fcd1d984a --- /dev/null +++ b/shared/src/androidMain/kotlin/com/fieldbook/shared/screens/statistics/ImageEncoder.android.kt @@ -0,0 +1,26 @@ +package com.fieldbook.shared.screens.statistics + +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import java.io.ByteArrayOutputStream + +actual fun encodePng(image: ImageBitmap): ByteArray? { + return runCatching { + val source = image.asAndroidBitmap() + val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && source.config == Bitmap.Config.HARDWARE) { + source.copy(Bitmap.Config.ARGB_8888, false) + } else { + source + } + + ByteArrayOutputStream().use { output -> + val encoded = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + if (bitmap !== source) { + bitmap.recycle() + } + if (encoded) output.toByteArray() else null + } + }.getOrNull() +} diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpApp.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpApp.kt index 49a080ebbd..84b599040d 100644 --- a/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpApp.kt +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpApp.kt @@ -39,6 +39,7 @@ import com.fieldbook.shared.screens.preferences.LanguageScreen import com.fieldbook.shared.screens.preferences.PreferencesScreen import com.fieldbook.shared.screens.preferences.StorageDefinerScreen import com.fieldbook.shared.screens.preferences.StoragePreferencesScreen +import com.fieldbook.shared.screens.statistics.StatisticsScreen import com.fieldbook.shared.screens.trait.TraitEditorScreen import kotlinx.coroutines.launch @@ -229,6 +230,13 @@ fun KmpApp( ) } + composable(KmpHostScreenType.STATISTICS.route) { + StatisticsScreen( + onBack = { navController.navigateBackOrExit(onExit) }, + onSnackbarMessage = onSnackbarMessage, + ) + } + composable(KmpHostScreenType.ABOUT.route) { AboutScreen( onBack = { navController.navigateBackOrExit(onExit) }, diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpHostScreenType.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpHostScreenType.kt index 937cece052..6170c36c86 100644 --- a/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpHostScreenType.kt +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/KmpHostScreenType.kt @@ -11,6 +11,7 @@ enum class KmpHostScreenType(val value: String) { BRAPI_FILTER("brapi_filter"), COLLECT("collect"), EXPORT("export"), + STATISTICS("statistics"), ABOUT("about"), PREFERENCES("preferences"), BRAPI_PREFERENCES("brapi_preferences"), diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/ConfigScreen.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/ConfigScreen.kt index 6df831a582..d8f049571b 100644 --- a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/ConfigScreen.kt +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/ConfigScreen.kt @@ -139,7 +139,8 @@ fun ConfigScreen( ), ConfigItem( title = Res.string.settings_statistics, - icon = Res.drawable.ic_nav_drawer_statistics + icon = Res.drawable.ic_nav_drawer_statistics, + destination = KmpHostScreenType.STATISTICS ), ConfigItem( title = Res.string.about_title, diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsHeatmapScreen.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsHeatmapScreen.kt new file mode 100644 index 0000000000..c52158545e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsHeatmapScreen.kt @@ -0,0 +1,414 @@ +package com.fieldbook.shared.screens.statistics + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.fieldbook.shared.generated.resources.Res +import com.fieldbook.shared.generated.resources.dialog_back +import com.fieldbook.shared.generated.resources.dialog_cancel +import com.fieldbook.shared.generated.resources.dialog_ok +import com.fieldbook.shared.generated.resources.ic_stats_calendar_range +import com.fieldbook.shared.generated.resources.ic_stats_counter +import com.fieldbook.shared.generated.resources.ic_stats_scroll_bottom +import com.fieldbook.shared.generated.resources.ic_stats_scroll_top +import com.fieldbook.shared.generated.resources.stats_calendar_range +import com.fieldbook.shared.generated.resources.stats_counter +import com.fieldbook.shared.generated.resources.stats_date_range_picker_title +import com.fieldbook.shared.generated.resources.stats_first_day +import com.fieldbook.shared.generated.resources.stats_heatmap_title +import com.fieldbook.shared.generated.resources.stats_last_day +import com.fieldbook.shared.generated.resources.warning_invalid_date_range +import com.fieldbook.shared.generated.resources.warning_no_observations +import com.fieldbook.shared.utilities.epochMillisToLocalDate +import com.fieldbook.shared.utilities.localDateToEpochMillis +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatisticsHeatmapScreen( + heatmap: StatisticsHeatmapState, + onBack: () -> Unit, + onToggleCounts: () -> Unit, + onRangeSelected: (LocalDate, LocalDate) -> Unit, + onSnackbarMessage: (String) -> Unit, +) { + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + var showRangePicker by remember { mutableStateOf(false) } + val noObservationsMessage = stringResource(Res.string.warning_no_observations) + + LaunchedEffect(heatmap.months.firstOrNull()?.year, heatmap.months.firstOrNull()?.monthNumber) { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val currentMonthIndex = heatmap.monthIndex(today) + if (currentMonthIndex >= 0) { + listState.scrollToItem(currentMonthIndex) + } else if (heatmap.months.isNotEmpty()) { + listState.scrollToItem(heatmap.months.lastIndex) + } + } + + if (showRangePicker) { + StatisticsHeatmapRangeDialog( + heatmap = heatmap, + onDismiss = { showRangePicker = false }, + onRangeSelected = { startDate, endDate -> + onRangeSelected(startDate, endDate) + showRangePicker = false + }, + onInvalidRange = { onSnackbarMessage(it) }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.stats_heatmap_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.dialog_back), + ) + } + }, + actions = { + IconButton( + onClick = { + val index = heatmap.firstObservationDateInRange?.let(heatmap::monthIndex) ?: -1 + if (index >= 0) { + coroutineScope.launch { listState.animateScrollToItem(index) } + } else { + onSnackbarMessage(noObservationsMessage) + } + }, + ) { + Icon( + painter = painterResource(Res.drawable.ic_stats_scroll_top), + contentDescription = stringResource(Res.string.stats_first_day), + ) + } + IconButton( + onClick = { + val index = heatmap.lastObservationDateInRange?.let(heatmap::monthIndex) ?: -1 + if (index >= 0) { + coroutineScope.launch { listState.animateScrollToItem(index) } + } else { + onSnackbarMessage(noObservationsMessage) + } + }, + ) { + Icon( + painter = painterResource(Res.drawable.ic_stats_scroll_bottom), + contentDescription = stringResource(Res.string.stats_last_day), + ) + } + IconButton(onClick = { showRangePicker = true }) { + Icon( + painter = painterResource(Res.drawable.ic_stats_calendar_range), + contentDescription = stringResource(Res.string.stats_calendar_range), + ) + } + IconButton(onClick = onToggleCounts) { + Icon( + painter = painterResource(Res.drawable.ic_stats_counter), + contentDescription = stringResource(Res.string.stats_counter), + tint = if (heatmap.showCounts) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.onPrimary + }, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + }, + ) { innerPadding -> + if (!heatmap.hasObservations) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Text( + text = noObservationsMessage, + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center, + ) + } + return@Scaffold + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items( + count = heatmap.months.size, + key = { index -> "${heatmap.months[index].year}-${heatmap.months[index].monthNumber}" }, + ) { index -> + StatisticsHeatmapMonthCard( + month = heatmap.months[index], + showCounts = heatmap.showCounts, + ) + } + } + } +} + +@Composable +private fun StatisticsHeatmapMonthCard( + month: StatisticsHeatmapMonth, + showCounts: Boolean, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = month.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + WeekdayHeader() + month.days.chunked(7).forEach { week -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + week.forEach { day -> + StatisticsHeatmapDayCell( + day = day, + showCounts = showCounts, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun WeekdayHeader() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun").forEach { day -> + Text( + text = day, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun StatisticsHeatmapDayCell( + day: StatisticsHeatmapDay, + showCounts: Boolean, + modifier: Modifier = Modifier, +) { + val date = day.date + Box( + modifier = modifier.aspectRatio(1f), + contentAlignment = Alignment.Center, + ) { + if (date != null) { + val dayColor = heatmapDayColor(day.count) + val textColor = when { + !day.inSelectedRange -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) + day.count > 0 -> Color.Black + else -> MaterialTheme.colorScheme.onSurface + } + val backgroundColor = when { + !day.inSelectedRange -> Color.Transparent + day.count > 0 -> dayColor + else -> Color.Transparent + } + val label = if (showCounts) day.count.toString() else date.dayOfMonth.toString() + + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontSize = 13.sp, + fontWeight = if (day.count > 0) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StatisticsHeatmapRangeDialog( + heatmap: StatisticsHeatmapState, + onDismiss: () -> Unit, + onRangeSelected: (LocalDate, LocalDate) -> Unit, + onInvalidRange: (String) -> Unit, +) { + var selectingStart by remember { mutableStateOf(true) } + var startDate by remember { mutableStateOf(heatmap.startDate ?: heatmap.availableStartDate) } + var endDate by remember { mutableStateOf(heatmap.endDate ?: heatmap.availableEndDate) } + val invalidRangeMessage = stringResource(Res.string.warning_invalid_date_range) + val activeDate = if (selectingStart) startDate else endDate + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + val start = startDate + val end = endDate + if (start == null || end == null || start > end) { + onInvalidRange(invalidRangeMessage) + } else { + onRangeSelected(start, end) + } + }, + ) { + Text(stringResource(Res.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.dialog_cancel)) + } + }, + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp)) { + Text( + text = stringResource(Res.string.stats_date_range_picker_title), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.titleLarge, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { selectingStart = true }, + modifier = Modifier.weight(1f), + ) { + Text("Start: ${startDate?.toShortText().orEmpty()}") + } + OutlinedButton( + onClick = { selectingStart = false }, + modifier = Modifier.weight(1f), + ) { + Text("End: ${endDate?.toShortText().orEmpty()}") + } + } + Spacer(modifier = Modifier.height(8.dp)) + key(selectingStart, activeDate) { + val pickerState = rememberDatePickerState( + initialSelectedDateMillis = activeDate?.let(::localDateToEpochMillis), + ) + LaunchedEffect(pickerState.selectedDateMillis) { + pickerState.selectedDateMillis?.let { selectedMillis -> + if (selectingStart) { + startDate = epochMillisToLocalDate(selectedMillis) + } else { + endDate = epochMillisToLocalDate(selectedMillis) + } + } + } + DatePicker(state = pickerState) + } + } + } +} + +private fun StatisticsHeatmapState.monthIndex(date: LocalDate): Int = + months.indexOfFirst { month -> month.year == date.year && month.monthNumber == date.monthNumber } + +private fun heatmapDayColor(count: Int): Color = when { + count <= 0 -> Color.Transparent + count == 1 -> Color(0xFFD7EFC1) + count < 5 -> Color(0xFFA9D97D) + count < 8 -> Color(0xFF77BE4B) + else -> Color(0xFF3F8E2F) +} + +private fun LocalDate.toShortText(): String = + "${monthNumber.toString().padStart(2, '0')}/${dayOfMonth.toString().padStart(2, '0')}/$year" diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsModels.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsModels.kt new file mode 100644 index 0000000000..20fa679958 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsModels.kt @@ -0,0 +1,402 @@ +@file:OptIn(ExperimentalTime::class) + +package com.fieldbook.shared.screens.statistics + +import kotlinx.datetime.Clock +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.ExperimentalTime + +private const val INTERVAL_THRESHOLD_MINUTES = 30L +private const val INTERVAL_THRESHOLD_SECONDS = INTERVAL_THRESHOLD_MINUTES * 60L + +data class StatisticsObservation( + val studyId: Long, + val studyName: String?, + val studyAlias: String?, + val observationUnitId: String, + val value: String?, + val timestamp: String?, + val collector: String?, + val observationVariableName: String?, + val observationVariableFieldBookFormat: String?, +) + +data class StatisticsSection( + val period: StatisticsPeriod, + val title: String, + val cards: List, +) + +data class StatisticsCard( + val type: StatisticsCardType, + val value: String, + val details: StatisticsCardDetails? = null, +) + +data class StatisticsCardDetails( + val title: String = "", + val lines: List = emptyList(), + val message: String? = null, +) + +data class StatisticsHeatmapState( + val dayCounts: Map = emptyMap(), + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, + val availableStartDate: LocalDate? = null, + val availableEndDate: LocalDate? = null, + val firstObservationDateInRange: LocalDate? = null, + val lastObservationDateInRange: LocalDate? = null, + val months: List = emptyList(), + val showCounts: Boolean = false, +) { + val hasObservations: Boolean = dayCounts.isNotEmpty() +} + +data class StatisticsHeatmapMonth( + val year: Int, + val monthNumber: Int, + val title: String, + val days: List, +) + +data class StatisticsHeatmapDay( + val date: LocalDate?, + val count: Int = 0, + val inSelectedRange: Boolean = false, +) + +enum class StatisticsMode { TOTAL, YEAR, MONTH } + +data class StatisticsPeriod( + val mode: StatisticsMode, + val key: String, +) + +enum class StatisticsCardType { + FIELDS, + ENTRIES, + DATA, + HOURS, + PEOPLE, + PHOTOS, + BUSIEST, + MOST, +} + +fun buildStatisticsSections( + observations: List, + mode: StatisticsMode, +): List { + val parseableObservations = observations.map { observation -> + ParsedStatisticsObservation( + observation = observation, + instant = parseFieldBookInstant(observation.timestamp), + ) + } + + return when (mode) { + StatisticsMode.TOTAL -> listOf( + buildStatisticsSection( + title = "Total", + period = StatisticsPeriod(mode, "total"), + observations = parseableObservations, + ) + ) + + StatisticsMode.YEAR -> parseableObservations + .groupBy { parsed -> parsed.localDate()?.year?.toString() ?: "Unknown" } + .entries + .sortedByDescending { it.key } + .map { (year, group) -> + buildStatisticsSection( + title = year, + period = StatisticsPeriod(mode, year), + observations = group, + ) + } + + StatisticsMode.MONTH -> parseableObservations + .groupBy { parsed -> parsed.localDate()?.let { "${it.year}-${it.monthNumber.toString().padStart(2, '0')}" } ?: "Unknown" } + .entries + .sortedByDescending { it.key } + .map { (month, group) -> + buildStatisticsSection( + title = monthTitle(month), + period = StatisticsPeriod(mode, month), + observations = group, + ) + } + } +} + +fun buildStatisticsHeatmap( + observations: List, + startDate: LocalDate? = null, + endDate: LocalDate? = null, + showCounts: Boolean = false, +): StatisticsHeatmapState { + val dateCounts = observations + .mapNotNull { observation -> parseFieldBookInstant(observation.timestamp)?.toLocalDate() } + .groupingBy { it } + .eachCount() + + if (dateCounts.isEmpty()) { + return StatisticsHeatmapState(showCounts = showCounts) + } + + val availableStartDate = dateCounts.keys.minOrNull() + val availableEndDate = dateCounts.keys.maxOrNull() + val selectedStart = startDate ?: availableStartDate + val selectedEnd = endDate ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val safeStart = selectedStart?.coerceAtMost(selectedEnd) + val safeEnd = selectedEnd.coerceAtLeast(selectedStart ?: selectedEnd) + val firstMonth = availableStartDate?.firstDayOfMonth() + val lastMonth = maxOf(safeEnd.firstDayOfMonth(), availableEndDate?.firstDayOfMonth() ?: safeEnd.firstDayOfMonth()) + val observationDatesInRange = if (safeStart != null) { + dateCounts.keys.filter { date -> date in safeStart..safeEnd } + } else { + emptyList() + } + + return StatisticsHeatmapState( + dayCounts = dateCounts, + startDate = safeStart, + endDate = safeEnd, + availableStartDate = availableStartDate, + availableEndDate = availableEndDate, + firstObservationDateInRange = observationDatesInRange.minOrNull(), + lastObservationDateInRange = observationDatesInRange.maxOrNull(), + months = if (firstMonth != null) buildHeatmapMonths( + firstMonth = firstMonth, + lastMonth = lastMonth, + dateCounts = dateCounts, + startDate = safeStart, + endDate = safeEnd, + ) else emptyList(), + showCounts = showCounts, + ) +} + +private fun buildStatisticsSection( + title: String, + period: StatisticsPeriod, + observations: List, +): StatisticsSection { + val source = observations.map { it.observation } + val fieldIds = source.map { it.studyId }.toSet() + val fieldNames = source + .distinctBy { it.studyId } + .map { it.studyAlias?.ifBlank { null } ?: it.studyName?.ifBlank { null } ?: it.studyId.toString() } + .sorted() + val observationUnits = source.map { it.observationUnitId }.toSet() + val collectors = source.mapNotNull { it.collector?.trim()?.takeIf(String::isNotEmpty) }.toSet().sorted() + val imageCount = source.count { it.observationVariableFieldBookFormat.isCameraTraitFormat() } + val dateCounts = observations + .mapNotNull { it.localDate()?.toDisplayDate() } + .groupingBy { it } + .eachCount() + val busiestDate = dateCounts.maxByOrNull { it.value } + val unitCounts = source.groupingBy { it.observationUnitId }.eachCount() + val mostObservedUnit = unitCounts.maxByOrNull { it.value } + val mostObservedUnitLines = mostObservedUnit?.key?.let { unitId -> + source + .filter { it.observationUnitId == unitId } + .map { observation -> + listOfNotNull( + observation.observationVariableName?.ifBlank { null }, + observation.value?.ifBlank { null }, + ).joinToString(": ").ifBlank { observation.observationUnitId } + } + }.orEmpty() + + val hours = activeHours(observations.mapNotNull { it.instant }) + + return StatisticsSection( + period = period, + title = title, + cards = listOf( + StatisticsCard( + type = StatisticsCardType.FIELDS, + value = fieldIds.size.toString(), + details = if (fieldNames.isNotEmpty()) StatisticsCardDetails( + title = "Fields imported in $title", + lines = fieldNames, + ) else null, + ), + StatisticsCard( + type = StatisticsCardType.ENTRIES, + value = observationUnits.size.toString(), + details = StatisticsCardDetails(message = "${observationUnits.size} entries have been phenotyped"), + ), + StatisticsCard( + type = StatisticsCardType.DATA, + value = source.size.toString(), + details = StatisticsCardDetails(message = "${source.size} observations have been collected"), + ), + StatisticsCard( + type = StatisticsCardType.HOURS, + value = hours, + details = StatisticsCardDetails(message = "$hours hours spent phenotyping"), + ), + StatisticsCard( + type = StatisticsCardType.PEOPLE, + value = collectors.size.toString(), + details = if (collectors.isNotEmpty()) StatisticsCardDetails( + title = "List of People", + lines = collectors, + ) else null, + ), + StatisticsCard( + type = StatisticsCardType.PHOTOS, + value = imageCount.toString(), + details = StatisticsCardDetails(message = "$imageCount photos have been captured"), + ), + StatisticsCard( + type = StatisticsCardType.BUSIEST, + value = busiestDate?.key ?: "-", + details = busiestDate?.let { + StatisticsCardDetails(message = "${it.value} observations were collected on ${it.key}") + }, + ), + StatisticsCard( + type = StatisticsCardType.MOST, + value = mostObservedUnit?.value?.toString() ?: "0", + details = mostObservedUnit?.let { + StatisticsCardDetails( + title = it.key, + lines = mostObservedUnitLines, + ) + }, + ), + ), + ) +} + +private data class ParsedStatisticsObservation( + val observation: StatisticsObservation, + val instant: Instant?, +) { + fun localDate(): LocalDate? = instant?.toLocalDateTime(TimeZone.currentSystemDefault())?.date +} + +private fun activeHours(instants: List): String { + val totalSeconds = instants + .sorted() + .zipWithNext() + .sumOf { (previous, next) -> + val seconds = next.epochSeconds - previous.epochSeconds + if (seconds in 0..INTERVAL_THRESHOLD_SECONDS) seconds else 0L + } + + return (totalSeconds / 3600.0).toStringWithTwoDecimals() +} + +internal fun parseFieldBookInstant(value: String?): Instant? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + + val normalized = trimmed.replace(' ', 'T') + val withoutFraction = normalized.replace(Regex("""\.\d{1,9}([+-]\d{2}:?\d{2}|Z)$"""), "$1") + val localDateTime = normalized.substringBefore('.').take(19) + + return runCatching { Instant.parse(normalized) }.getOrNull() + ?: runCatching { Instant.parse(withoutFraction) }.getOrNull() + ?: runCatching { LocalDateTime.parse(localDateTime).toInstant(TimeZone.currentSystemDefault()) }.getOrNull() +} + +private fun Instant.toLocalDate(): LocalDate = + toLocalDateTime(TimeZone.currentSystemDefault()).date + +private fun LocalDate.firstDayOfMonth(): LocalDate = LocalDate(year, monthNumber, 1) + +private fun LocalDate.coerceAtMost(maximumValue: LocalDate): LocalDate = + if (this > maximumValue) maximumValue else this + +private fun LocalDate.coerceAtLeast(minimumValue: LocalDate): LocalDate = + if (this < minimumValue) minimumValue else this + +private fun buildHeatmapMonths( + firstMonth: LocalDate, + lastMonth: LocalDate, + dateCounts: Map, + startDate: LocalDate?, + endDate: LocalDate?, +): List { + val months = mutableListOf() + var cursor = firstMonth + while (cursor <= lastMonth) { + months += buildHeatmapMonth(cursor, dateCounts, startDate, endDate) + cursor = cursor.plus(DatePeriod(months = 1)) + } + return months +} + +private fun buildHeatmapMonth( + monthDate: LocalDate, + dateCounts: Map, + startDate: LocalDate?, + endDate: LocalDate?, +): StatisticsHeatmapMonth { + val firstDayOffset = monthDate.dayOfWeek.ordinal + val nextMonth = monthDate.plus(DatePeriod(months = 1)) + val daysInMonth: Int = (nextMonth.toEpochDays() - monthDate.toEpochDays()).toInt() + val cells = mutableListOf() + + repeat(firstDayOffset) { + cells += StatisticsHeatmapDay(date = null) + } + + repeat(daysInMonth) { index -> + val date = monthDate.plus(DatePeriod(days = index)) + cells += StatisticsHeatmapDay( + date = date, + count = dateCounts[date] ?: 0, + inSelectedRange = startDate != null && endDate != null && date in startDate..endDate, + ) + } + + while (cells.size % 7 != 0) { + cells += StatisticsHeatmapDay(date = null) + } + + return StatisticsHeatmapMonth( + year = monthDate.year, + monthNumber = monthDate.monthNumber, + title = monthTitle("${monthDate.year}-${monthDate.monthNumber.toString().padStart(2, '0')}"), + days = cells, + ) +} + +private fun LocalDate.toDisplayDate(): String = + "${monthNumber.toString().padStart(2, '0')}-${dayOfMonth.toString().padStart(2, '0')}-${(year % 100).toString().padStart(2, '0')}" + +private fun monthTitle(monthKey: String): String { + val parts = monthKey.split('-') + val year = parts.getOrNull(0)?.toIntOrNull() ?: return monthKey + val monthNumber = parts.getOrNull(1)?.toIntOrNull() ?: return monthKey + val monthName = Month.entries.getOrNull(monthNumber - 1)?.name + ?.lowercase() + ?.replaceFirstChar { it.titlecase() } + ?: return monthKey + return "$monthName $year" +} + +private fun String?.isCameraTraitFormat(): Boolean { + return this == "photo" || this == "usb camera" || this == "gopro" || this == "canon" +} + +private fun Double.toStringWithTwoDecimals(): String { + val totalCents = kotlin.math.round(this * 100.0).toLong() + val whole = totalCents / 100 + val cents = (totalCents % 100).toString().padStart(2, '0') + return "$whole.$cents" +} diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsRepository.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsRepository.kt new file mode 100644 index 0000000000..92bb9a786e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsRepository.kt @@ -0,0 +1,29 @@ +package com.fieldbook.shared.screens.statistics + +import com.fieldbook.shared.sqldelight.FieldbookDatabase +import com.fieldbook.shared.sqldelight.createDatabase + +class StatisticsRepository( + private val dbProvider: () -> FieldbookDatabase = { createDatabase() }, +) { + private val db: FieldbookDatabase + get() = dbProvider() + + fun getObservations(): List { + return db.observationsQueries.getStatisticsObservations() + .executeAsList() + .map { row -> + StatisticsObservation( + studyId = row.study_id, + studyName = row.study_name, + studyAlias = row.study_alias, + observationUnitId = row.observation_unit_id, + value = row.value_, + timestamp = row.observation_time_stamp, + collector = row.collector, + observationVariableName = row.observation_variable_name, + observationVariableFieldBookFormat = row.observation_variable_field_book_format, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsScreen.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsScreen.kt new file mode 100644 index 0000000000..5222c8b7d8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsScreen.kt @@ -0,0 +1,619 @@ +package com.fieldbook.shared.screens.statistics + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.GraphicsContext +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalGraphicsContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.fieldbook.shared.generated.resources.Res +import com.fieldbook.shared.generated.resources.dialog_back +import com.fieldbook.shared.generated.resources.dialog_ok +import com.fieldbook.shared.generated.resources.ic_stats_busiest +import com.fieldbook.shared.generated.resources.ic_stats_calendar +import com.fieldbook.shared.generated.resources.ic_stats_export +import com.fieldbook.shared.generated.resources.ic_stats_field +import com.fieldbook.shared.generated.resources.ic_stats_most_obs +import com.fieldbook.shared.generated.resources.ic_stats_observation +import com.fieldbook.shared.generated.resources.ic_stats_people +import com.fieldbook.shared.generated.resources.ic_stats_photo +import com.fieldbook.shared.generated.resources.ic_stats_plot +import com.fieldbook.shared.generated.resources.ic_stats_time +import com.fieldbook.shared.generated.resources.settings_statistics +import com.fieldbook.shared.generated.resources.stat_title_busiest +import com.fieldbook.shared.generated.resources.stat_title_data +import com.fieldbook.shared.generated.resources.stat_title_entries +import com.fieldbook.shared.generated.resources.stat_title_fields +import com.fieldbook.shared.generated.resources.stat_title_hours +import com.fieldbook.shared.generated.resources.stat_title_most +import com.fieldbook.shared.generated.resources.stat_title_people +import com.fieldbook.shared.generated.resources.stat_title_photos +import com.fieldbook.shared.generated.resources.stats_heatmap +import com.fieldbook.shared.generated.resources.stats_tab_layout_month +import com.fieldbook.shared.generated.resources.stats_tab_layout_total +import com.fieldbook.shared.generated.resources.stats_tab_layout_year +import com.fieldbook.shared.theme.AlertDialog +import com.fieldbook.shared.theme.TextButton +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatisticsScreen( + onBack: () -> Unit, + onSnackbarMessage: (String) -> Unit, + viewModel: StatisticsScreenViewModel = viewModel( + factory = statisticsScreenViewModelFactory() + ), +) { + val uiState by viewModel.uiState.collectAsState() + var selectedDetails by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.load() + } + + if (uiState.showHeatmap) { + StatisticsHeatmapScreen( + heatmap = uiState.heatmap, + onBack = viewModel::closeHeatmap, + onToggleCounts = viewModel::toggleHeatmapCounts, + onRangeSelected = viewModel::setHeatmapRange, + onSnackbarMessage = onSnackbarMessage, + ) + return + } + + selectedDetails?.let { details -> + StatisticsDetailsDialog( + details = details, + onDismiss = { selectedDetails = null }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.settings_statistics)) }, + actions = { + IconButton(onClick = viewModel::openHeatmap) { + Icon( + painter = painterResource(Res.drawable.ic_stats_calendar), + contentDescription = stringResource(Res.string.stats_heatmap), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.dialog_back), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + StatisticsModeSelector( + selectedMode = uiState.mode, + onSelectMode = viewModel::setMode, + ) + + when { + uiState.loading -> Box(Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + uiState.error != null -> Box(Modifier.fillMaxSize()) { + Text( + text = uiState.error.orEmpty(), + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.error, + ) + } + + uiState.sections.all { section -> section.cards.all { it.value == "0" || it.value == "-" || it.value == "0.00" } } -> Box(Modifier.fillMaxSize()) { + Text( + text = "No statistics available.", + modifier = Modifier.align(Alignment.Center), + ) + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(uiState.sections) { section -> + StatisticsSectionCard( + section = section, + onShareClick = { image -> + val shared = shareStatisticsSection(section, image) + if (!shared) { + onSnackbarMessage("Unable to share statistics image.") + } + }, + onShareCaptureFailed = { + onSnackbarMessage("Unable to capture statistics image.") + }, + onCardClick = { card -> + val details = card.details ?: return@StatisticsSectionCard + if (details.lines.isNotEmpty()) { + selectedDetails = details + } else { + details.message?.let(onSnackbarMessage) + } + }, + ) + } + } + } + } + } +} + +@Composable +private fun StatisticsModeSelector( + selectedMode: StatisticsMode, + onSelectMode: (StatisticsMode) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatisticsMode.entries.forEach { mode -> + val selected = selectedMode == mode + OutlinedButton( + onClick = { onSelectMode(mode) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + ), + border = BorderStroke( + width = 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + ) { + Text(stringResource(mode.titleResource())) + } + } + } +} + +@Composable +private fun StatisticsSectionCard( + section: StatisticsSection, + onShareClick: suspend (ImageBitmap) -> Unit, + onShareCaptureFailed: () -> Unit, + onCardClick: (StatisticsCard) -> Unit, +) { + val graphicsContext = LocalGraphicsContext.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val textMeasurer = rememberTextMeasurer() + val graphicsLayer = remember(graphicsContext) { graphicsContext.createGraphicsLayer() } + val coroutineScope = rememberCoroutineScope() + val exportTiles = section.cards.map { card -> + StatisticsExportTile( + label = stringResource(card.type.titleResource()), + value = card.value, + ) + } + + DisposableEffect(graphicsContext, graphicsLayer) { + onDispose { + graphicsContext.releaseGraphicsLayer(graphicsLayer) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + val layerSize = IntSize( + width = size.width.roundToInt(), + height = size.height.roundToInt(), + ) + if (layerSize.width > 0 && layerSize.height > 0) { + val contentDrawScope = this + graphicsLayer.record( + density = this, + layoutDirection = layoutDirection, + size = layerSize, + ) { + contentDrawScope.drawContent() + } + drawLayer(graphicsLayer) + } else { + drawContent() + } + }, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = section.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + IconButton( + onClick = { + coroutineScope.launch { + val capturedImage = runCatching { + graphicsLayer.toImageBitmap() + }.getOrNull() + val capturedHasPixels = capturedImage + ?.let { image -> runCatching { image.hasVisiblePixels() }.getOrDefault(false) } + ?: false + val image = if (capturedImage != null && capturedHasPixels) { + capturedImage + } else { + renderStatisticsSectionImage( + graphicsContext = graphicsContext, + density = density, + layoutDirection = layoutDirection, + textMeasurer = textMeasurer, + title = section.title, + tiles = exportTiles, + preferredSize = graphicsLayer.size, + ) + } + if (image != null) { + onShareClick(image) + } else { + onShareCaptureFailed() + } + } + } + ) { + Icon( + painter = painterResource(Res.drawable.ic_stats_export), + contentDescription = "Share statistics image", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val columns = if (maxWidth >= 560.dp) 4 else 2 + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + section.cards.chunked(columns).forEach { rowCards -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + rowCards.forEach { card -> + StatisticTile( + card = card, + modifier = Modifier.weight(1f), + onClick = { onCardClick(card) }, + ) + } + repeat(columns - rowCards.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } + } + } +} + +private data class StatisticsExportTile( + val label: String, + val value: String, +) + +/** + * Fallback renderer for Compose 1.7.3, where recording drawContent() into a manually-created + * GraphicsLayer can produce a correctly-sized but fully-transparent ImageBitmap. + */ +private suspend fun renderStatisticsSectionImage( + graphicsContext: GraphicsContext, + density: Density, + layoutDirection: LayoutDirection, + textMeasurer: TextMeasurer, + title: String, + tiles: List, + preferredSize: IntSize, +): ImageBitmap? { + val exportSize = preferredSize.takeIf { it.width > 0 && it.height > 0 } + ?: IntSize(960, 620) + val exportLayer = graphicsContext.createGraphicsLayer() + + return try { + exportLayer.record( + density = density, + layoutDirection = layoutDirection, + size = exportSize, + ) { + val canvasWidth = size.width + val canvasHeight = size.height + val padding = 28.dp.toPx() + val gap = 10.dp.toPx() + val columns = if (canvasWidth >= 560.dp.toPx()) 4 else 2 + val rows = (tiles.size + columns - 1) / columns + val headerHeight = 70.dp.toPx() + val tileWidth = (canvasWidth - padding * 2 - gap * (columns - 1)) / columns + val tileHeight = ((canvasHeight - padding * 2 - headerHeight - gap * (rows - 1)) / rows) + .coerceAtLeast(74.dp.toPx()) + + drawRect(Color.White, size = Size(canvasWidth, canvasHeight)) + drawRoundRect( + color = Color(0xFFF8FBF4), + topLeft = Offset(6.dp.toPx(), 6.dp.toPx()), + size = Size(canvasWidth - 12.dp.toPx(), canvasHeight - 12.dp.toPx()), + cornerRadius = CornerRadius(18.dp.toPx(), 18.dp.toPx()), + ) + + val titleLayout = textMeasurer.measure( + text = title, + style = TextStyle( + color = Color(0xFF1B1B1B), + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + drawText( + textLayoutResult = titleLayout, + topLeft = Offset(padding, padding), + ) + + tiles.forEachIndexed { index, tile -> + val column = index % columns + val row = index / columns + val left = padding + column * (tileWidth + gap) + val top = padding + headerHeight + row * (tileHeight + gap) + drawRoundRect( + color = Color(0xFFEDF6E5), + topLeft = Offset(left, top), + size = Size(tileWidth, tileHeight), + cornerRadius = CornerRadius(12.dp.toPx(), 12.dp.toPx()), + ) + + val valueLayout = textMeasurer.measure( + text = tile.value, + style = TextStyle( + color = Color(0xFF111111), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + drawText( + textLayoutResult = valueLayout, + topLeft = Offset( + x = left + (tileWidth - valueLayout.size.width) / 2f, + y = top + tileHeight * 0.32f - valueLayout.size.height / 2f, + ), + ) + + val labelLayout = textMeasurer.measure( + text = tile.label, + style = TextStyle( + color = Color(0xFF333333), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + drawText( + textLayoutResult = labelLayout, + topLeft = Offset( + x = left + (tileWidth - labelLayout.size.width) / 2f, + y = top + tileHeight * 0.68f - labelLayout.size.height / 2f, + ), + ) + } + } + exportLayer.toImageBitmap() + } finally { + graphicsContext.releaseGraphicsLayer(exportLayer) + } +} + +private fun ImageBitmap.hasVisiblePixels(): Boolean { + if (width <= 0 || height <= 0) return false + + val pixels = IntArray(width * height) + readPixels(pixels) + return pixels.any { pixel -> + (pixel ushr 24) != 0 + } +} + +@Composable +private fun StatisticTile( + card: StatisticsCard, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val hasDetails = card.details != null + Surface( + modifier = modifier + .clickable(enabled = hasDetails, onClick = onClick), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.28f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(card.type.iconResource()), + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = card.value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + Text( + text = stringResource(card.type.titleResource()), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } +} + +@Composable +private fun StatisticsDetailsDialog( + details: StatisticsCardDetails, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(details.title.ifBlank { stringResource(Res.string.settings_statistics) }) }, + text = { + if (details.lines.isEmpty()) { + Text(details.message.orEmpty()) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(details.lines) { line -> + Text(line) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.dialog_ok)) + } + }, + ) +} + +private fun StatisticsMode.titleResource(): StringResource = when (this) { + StatisticsMode.TOTAL -> Res.string.stats_tab_layout_total + StatisticsMode.YEAR -> Res.string.stats_tab_layout_year + StatisticsMode.MONTH -> Res.string.stats_tab_layout_month +} + +private fun StatisticsCardType.titleResource(): StringResource = when (this) { + StatisticsCardType.FIELDS -> Res.string.stat_title_fields + StatisticsCardType.ENTRIES -> Res.string.stat_title_entries + StatisticsCardType.DATA -> Res.string.stat_title_data + StatisticsCardType.HOURS -> Res.string.stat_title_hours + StatisticsCardType.PEOPLE -> Res.string.stat_title_people + StatisticsCardType.PHOTOS -> Res.string.stat_title_photos + StatisticsCardType.BUSIEST -> Res.string.stat_title_busiest + StatisticsCardType.MOST -> Res.string.stat_title_most +} + +private fun StatisticsCardType.iconResource(): DrawableResource = when (this) { + StatisticsCardType.FIELDS -> Res.drawable.ic_stats_field + StatisticsCardType.ENTRIES -> Res.drawable.ic_stats_plot + StatisticsCardType.DATA -> Res.drawable.ic_stats_observation + StatisticsCardType.HOURS -> Res.drawable.ic_stats_time + StatisticsCardType.PEOPLE -> Res.drawable.ic_stats_people + StatisticsCardType.PHOTOS -> Res.drawable.ic_stats_photo + StatisticsCardType.BUSIEST -> Res.drawable.ic_stats_busiest + StatisticsCardType.MOST -> Res.drawable.ic_stats_most_obs +} diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsScreenViewModel.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsScreenViewModel.kt new file mode 100644 index 0000000000..404cc6f6f2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsScreenViewModel.kt @@ -0,0 +1,120 @@ +package com.fieldbook.shared.screens.statistics + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDate + +data class StatisticsUiState( + val loading: Boolean = true, + val mode: StatisticsMode = StatisticsMode.TOTAL, + val sections: List = emptyList(), + val showHeatmap: Boolean = false, + val heatmap: StatisticsHeatmapState = StatisticsHeatmapState(), + val error: String? = null, +) + +class StatisticsScreenViewModel( + private val repository: StatisticsRepository = StatisticsRepository(), +) : ViewModel() { + private val _uiState = MutableStateFlow(StatisticsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var observations: List = emptyList() + + fun load() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(loading = true, error = null) + runCatching { + withContext(Dispatchers.Default) { + repository.getObservations() + } + }.onSuccess { loadedObservations -> + observations = loadedObservations + val mode = _uiState.value.mode + _uiState.value = StatisticsUiState( + loading = false, + mode = mode, + showHeatmap = _uiState.value.showHeatmap, + sections = buildStatisticsSections(loadedObservations, mode), + heatmap = buildStatisticsHeatmap(loadedObservations), + ) + }.onFailure { throwable -> + _uiState.value = StatisticsUiState( + loading = false, + mode = _uiState.value.mode, + error = throwable.message ?: "Unable to load statistics", + ) + } + } + } + + fun setMode(mode: StatisticsMode) { + if (_uiState.value.mode == mode) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(mode = mode, loading = true, error = null) + runCatching { + withContext(Dispatchers.Default) { + val source = observations.ifEmpty { + repository.getObservations().also { observations = it } + } + buildStatisticsSections(source, mode) + } + }.onSuccess { sections -> + _uiState.value = _uiState.value.copy( + loading = false, + sections = sections, + ) + }.onFailure { throwable -> + _uiState.value = _uiState.value.copy( + loading = false, + error = throwable.message ?: "Unable to load statistics", + ) + } + } + } + + fun openHeatmap() { + _uiState.value = _uiState.value.copy(showHeatmap = true) + } + + fun closeHeatmap() { + _uiState.value = _uiState.value.copy(showHeatmap = false) + } + + fun toggleHeatmapCounts() { + val current = _uiState.value.heatmap + _uiState.value = _uiState.value.copy( + heatmap = buildStatisticsHeatmap( + observations = observations, + startDate = current.startDate, + endDate = current.endDate, + showCounts = !current.showCounts, + ) + ) + } + + fun setHeatmapRange(startDate: LocalDate, endDate: LocalDate) { + _uiState.value = _uiState.value.copy( + heatmap = buildStatisticsHeatmap( + observations = observations, + startDate = startDate, + endDate = endDate, + showCounts = _uiState.value.heatmap.showCounts, + ) + ) + } +} + +fun statisticsScreenViewModelFactory() = viewModelFactory { + initializer { + StatisticsScreenViewModel() + } +} diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsShare.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsShare.kt new file mode 100644 index 0000000000..9bbe3e7a88 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/screens/statistics/StatisticsShare.kt @@ -0,0 +1,45 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package com.fieldbook.shared.screens.statistics + +import androidx.compose.ui.graphics.ImageBitmap +import com.fieldbook.shared.generated.resources.Res +import com.fieldbook.shared.generated.resources.dir_media_photos +import com.fieldbook.shared.utilities.getDirectory +import com.fieldbook.shared.utilities.shareFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock + +suspend fun shareStatisticsSection( + section: StatisticsSection, + image: ImageBitmap, +): Boolean { + val imageBytes = encodePng(image) ?: return false + val photosDir = getDirectory(Res.string.dir_media_photos) ?: return false + val fileName = "${section.exportFileStem()}_${Clock.System.now().toEpochMilliseconds()}.png" + val file = photosDir.createFile("image/png", fileName) ?: return false + + withContext(Dispatchers.Default) { + file.writeBytes(imageBytes) + } + shareFile(file) + return true +} + +expect fun encodePng(image: ImageBitmap): ByteArray? + +private fun StatisticsSection.exportFileStem(): String { + val mode = when (period.mode) { + StatisticsMode.TOTAL -> "total" + StatisticsMode.YEAR -> "year" + StatisticsMode.MONTH -> "month" + } + val key = period.key + .lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifBlank { "statistics" } + + return "field-book-stats-$mode-$key" +} diff --git a/shared/src/commonMain/kotlin/com/fieldbook/shared/utilities/StorageDirectoryUtil.kt b/shared/src/commonMain/kotlin/com/fieldbook/shared/utilities/StorageDirectoryUtil.kt index dcdf2f97b7..855ad4eabd 100644 --- a/shared/src/commonMain/kotlin/com/fieldbook/shared/utilities/StorageDirectoryUtil.kt +++ b/shared/src/commonMain/kotlin/com/fieldbook/shared/utilities/StorageDirectoryUtil.kt @@ -6,6 +6,8 @@ import com.fieldbook.shared.generated.resources.dir_database import com.fieldbook.shared.generated.resources.dir_field_export import com.fieldbook.shared.generated.resources.dir_field_import import com.fieldbook.shared.generated.resources.dir_geonav +import com.fieldbook.shared.generated.resources.dir_media_audio +import com.fieldbook.shared.generated.resources.dir_media_photos import com.fieldbook.shared.generated.resources.dir_plot_data import com.fieldbook.shared.generated.resources.dir_preferences import com.fieldbook.shared.generated.resources.dir_resources @@ -27,6 +29,8 @@ fun defaultStorageDirectoryNames(): List = runBlocking { getString(Res.string.dir_trait), getString(Res.string.dir_updates), getString(Res.string.dir_preferences), + getString(Res.string.dir_media_photos), + getString(Res.string.dir_media_audio), ) } diff --git a/shared/src/commonMain/sqldelight/com/fieldbook/shared/sqldelight/observations.sq b/shared/src/commonMain/sqldelight/com/fieldbook/shared/sqldelight/observations.sq index eabf7e0a77..658134993d 100644 --- a/shared/src/commonMain/sqldelight/com/fieldbook/shared/sqldelight/observations.sq +++ b/shared/src/commonMain/sqldelight/com/fieldbook/shared/sqldelight/observations.sq @@ -160,6 +160,24 @@ WHERE obs.study_id = ? AND vars.trait_data_source IS NOT NULL AND vars.observation_variable_field_book_format = 'photo'; +getStatisticsObservations: +SELECT + obs.study_id, + study.study_name, + study.study_alias, + obs.observation_unit_id, + obs.value, + obs.observation_time_stamp, + obs.collector, + vars.observation_variable_name, + vars.observation_variable_field_book_format +FROM observations AS obs +LEFT JOIN studies AS study + ON study.internal_id_study = obs.study_id +LEFT JOIN observation_variables AS vars + ON vars.internal_id_observation_variable = obs.observation_variable_db_id +ORDER BY obs.observation_time_stamp COLLATE NOCASE ASC; + countLocalBrapiExportObservations: SELECT COUNT(*) FROM observations AS obs diff --git a/shared/src/iosMain/kotlin/com/fieldbook/shared/screens/statistics/ImageEncoder.ios.kt b/shared/src/iosMain/kotlin/com/fieldbook/shared/screens/statistics/ImageEncoder.ios.kt new file mode 100644 index 0000000000..a7638542b4 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/fieldbook/shared/screens/statistics/ImageEncoder.ios.kt @@ -0,0 +1,13 @@ +package com.fieldbook.shared.screens.statistics + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image + +actual fun encodePng(image: ImageBitmap): ByteArray? { + return runCatching { + val skiaImage = Image.makeFromBitmap(image.asSkiaBitmap()) + skiaImage.encodeToData(EncodedImageFormat.PNG, 100)?.bytes + }.getOrNull() +}