From a0905a1ab53aef59736122ea43718e9854ea9c46 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:32:30 -0700 Subject: [PATCH 1/2] Add Prune History Option --- .../java/org/cssnr/parking/MainActivity.kt | 33 ++++++++ .../cssnr/parking/data/HistoryRepository.kt | 7 ++ .../cssnr/parking/data/SettingsRepository.kt | 35 +++++++- .../org/cssnr/parking/data/db/HistoryDao.kt | 3 + .../parking/ui/screens/SettingsScreen.kt | 79 +++++++++++++++++++ .../parking/ui/viewmodel/SettingsViewModel.kt | 13 +++ app/src/main/res/values/strings.xml | 11 +++ 7 files changed, 180 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/cssnr/parking/MainActivity.kt b/app/src/main/java/org/cssnr/parking/MainActivity.kt index 4c802cb..8e65a30 100644 --- a/app/src/main/java/org/cssnr/parking/MainActivity.kt +++ b/app/src/main/java/org/cssnr/parking/MainActivity.kt @@ -1,6 +1,7 @@ package org.cssnr.parking import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -9,6 +10,12 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.cssnr.parking.data.HistoryRepository +import org.cssnr.parking.data.SettingsRepository +import org.cssnr.parking.data.db.AppDatabase import org.cssnr.parking.ui.ParKingApp import org.cssnr.parking.ui.theme.ParKingTheme @@ -16,6 +23,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + pruneExpiredHistory() enableEdgeToEdge() setContent { ParKingTheme { @@ -25,6 +33,31 @@ class MainActivity : ComponentActivity() { } } } + + /** + * Deletes history older than the retention setting, once per cold start. + * + * Fire-and-forget on purpose: pruning is housekeeping nobody waits on, and + * a failure only leaves stale rows until the next launch, so it is logged + * and dropped. Indefinitely (null retention) skips the query entirely. + */ + private fun pruneExpiredHistory() { + lifecycleScope.launch { + runCatching { + val context = applicationContext + val retention = SettingsRepository.retentionMillis( + SettingsRepository(context).historyDuration.first(), + ) ?: return@launch + val removed = HistoryRepository(AppDatabase.getDatabase(context)) + .prune(System.currentTimeMillis() - retention) + Log.d(TAG, "Pruned $removed expired history items") + }.onFailure { Log.w(TAG, "history prune failed: ${it.message}") } + } + } + + private companion object { + const val TAG = "MainActivity" + } } @Preview(showBackground = true) diff --git a/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt b/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt index 4190aa6..e3ace83 100644 --- a/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt +++ b/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt @@ -22,4 +22,11 @@ class HistoryRepository(database: AppDatabase) { suspend fun delete(id: Long) { historyDao.deleteById(id) } + + /** + * Deletes every record older than [cutoff] (epoch millis) and returns how + * many rows went away. The caller derives [cutoff] from the retention + * setting; null retention (Indefinitely) means don't call this at all. + */ + suspend fun prune(cutoff: Long): Int = historyDao.deleteOlderThan(cutoff) } \ No newline at end of file diff --git a/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt b/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt index 280012d..f33f094 100644 --- a/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt +++ b/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt @@ -3,11 +3,22 @@ package org.cssnr.parking.data import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class SettingsRepository(private val context: Context) { + val historyDuration: Flow = context.parkingDataStore.data.map { preferences -> + preferences[HISTORY_DURATION] ?: DEFAULT_HISTORY_DURATION + } + + suspend fun setHistoryDuration(index: Int) { + context.parkingDataStore.edit { preferences -> + preferences[HISTORY_DURATION] = index.coerceIn(0, HISTORY_DURATION_STEPS) + } + } + val crashReporting: Flow = context.parkingDataStore.data.map { preferences -> preferences[CRASH_REPORTING] ?: true } @@ -18,7 +29,29 @@ class SettingsRepository(private val context: Context) { } } - private companion object { + companion object { + // 0=1 week | 1=2 weeks | 2=1 month | 3=3 months | 4=6 months | 5=1 year | 6=Indefinitely + const val HISTORY_DURATION_STEPS = 6 + const val DEFAULT_HISTORY_DURATION = 3 + val HISTORY_DURATION = intPreferencesKey("history_duration") val CRASH_REPORTING = booleanPreferencesKey("acra.enable") + + /** + * Retention in millis for a history duration index, or null to keep + * everything (Indefinitely). Months are 30-day approximations: history + * pruning is housekeeping, not billing, so calendar exactness buys + * nothing here. + */ + fun retentionMillis(index: Int): Long? = when (index.coerceIn(0, HISTORY_DURATION_STEPS)) { + 0 -> 7L * DAY_MILLIS + 1 -> 14L * DAY_MILLIS + 2 -> 30L * DAY_MILLIS + 3 -> 90L * DAY_MILLIS + 4 -> 180L * DAY_MILLIS + 5 -> 365L * DAY_MILLIS + else -> null + } + + private const val DAY_MILLIS = 24L * 60 * 60 * 1000 } } \ No newline at end of file diff --git a/app/src/main/java/org/cssnr/parking/data/db/HistoryDao.kt b/app/src/main/java/org/cssnr/parking/data/db/HistoryDao.kt index 59f5280..4ec389c 100644 --- a/app/src/main/java/org/cssnr/parking/data/db/HistoryDao.kt +++ b/app/src/main/java/org/cssnr/parking/data/db/HistoryDao.kt @@ -22,4 +22,7 @@ interface HistoryDao { @Query("DELETE FROM history WHERE id = :id") suspend fun deleteById(id: Long) + + @Query("DELETE FROM history WHERE timestamp < :cutoff") + suspend fun deleteOlderThan(cutoff: Long): Int } \ No newline at end of file diff --git a/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt b/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt index 297f3b4..1b24f62 100644 --- a/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt @@ -5,23 +5,29 @@ 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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -41,6 +47,7 @@ import org.cssnr.parking.ui.components.SettingsGroup import org.cssnr.parking.ui.components.SettingsTile import org.cssnr.parking.ui.theme.ParKingTheme import org.cssnr.parking.ui.viewmodel.SettingsViewModel +import kotlin.math.roundToInt @Composable fun SettingsRoute( @@ -51,9 +58,12 @@ fun SettingsRoute( ) { val context = LocalContext.current val acraInfoLink = stringResource(R.string.acra_info_link) + val historyDuration by viewModel.historyDuration.collectAsStateWithLifecycle() val crashReporting by viewModel.crashReporting.collectAsStateWithLifecycle() SettingsScreen( + historyDuration = historyDuration, + onHistoryDurationChange = viewModel::setHistoryDuration, crashReporting = crashReporting, onCrashReportingChange = viewModel::setCrashReporting, onCrashReportingMoreInfo = { @@ -70,6 +80,8 @@ fun SettingsRoute( @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( + historyDuration: Int, + onHistoryDurationChange: (Int) -> Unit, crashReporting: Boolean, onCrashReportingChange: (Boolean) -> Unit, onCrashReportingMoreInfo: () -> Unit, @@ -100,6 +112,32 @@ fun SettingsScreen( .padding(innerPadding) .verticalScroll(rememberScrollState()), ) { + val historyLabels = listOf( + stringResource(R.string.history_duration_1_week), + stringResource(R.string.history_duration_2_weeks), + stringResource(R.string.history_duration_1_month), + stringResource(R.string.history_duration_3_months), + stringResource(R.string.history_duration_6_months), + stringResource(R.string.history_duration_1_year), + stringResource(R.string.history_duration_indefinitely), + ) + SettingsGroup( + title = stringResource(R.string.settings_group_application), + tiles = listOf( + SettingsTile.Custom( + icon = rememberVectorPainter(Icons.Filled.History), + title = stringResource(R.string.settings_history_duration), + summary = stringResource(R.string.settings_history_duration_summary), + content = { + HistoryDurationSlider( + value = historyDuration, + labels = historyLabels, + onSelect = onHistoryDurationChange, + ) + }, + ), + ), + ) SettingsGroup( title = stringResource(R.string.settings_group_debug), tiles = listOf( @@ -171,9 +209,50 @@ fun SettingsScreen( fun SettingsScreenPreview() { ParKingTheme { SettingsScreen( + historyDuration = 3, + onHistoryDurationChange = {}, crashReporting = true, onCrashReportingChange = {}, onCrashReportingMoreInfo = {}, ) } +} + +/** + * Discrete M3 slider with 7 stops: + * 1 week | 2 weeks | 1 month | 2 months | 6 months | 1 year | Indefinitely. + * + * 7 stops on 0f..6f means 5 intermediate steps, which draws the notch/tick at + * each stop. Persists only onValueChangeFinished to avoid DataStore writes + * on every drag frame. + */ +@Composable +private fun HistoryDurationSlider( + value: Int, + labels: List, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val coerced = value.coerceIn(0, labels.lastIndex) + val valueRange = 0f..labels.lastIndex.toFloat() + var sliderValue by remember { mutableFloatStateOf(coerced.toFloat()) } + LaunchedEffect(coerced) { + sliderValue = coerced.toFloat() + } + Column(modifier = modifier.fillMaxWidth()) { + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + valueRange = valueRange, + steps = labels.size - 2, + onValueChangeFinished = { + onSelect(sliderValue.roundToInt().coerceIn(0, labels.lastIndex)) + }, + ) + Text( + text = labels[sliderValue.roundToInt().coerceIn(0, labels.lastIndex)], + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt index 9e295de..d759b73 100644 --- a/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt @@ -13,6 +13,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private val settingsRepository = SettingsRepository(application) + val historyDuration: StateFlow = settingsRepository.historyDuration + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = SettingsRepository.DEFAULT_HISTORY_DURATION, + ) + + fun setHistoryDuration(index: Int) { + viewModelScope.launch { + settingsRepository.setHistoryDuration(index) + } + } + val crashReporting: StateFlow = settingsRepository.crashReporting .stateIn( scope = viewModelScope, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52ff420..2636a4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,17 @@ ParKing Settings + Application + History Duration + How long to keep history. + 1 week + 2 weeks + 1 month + 3 months + 6 months + 1 year + Indefinitely + Debug Enable Crash Reporting Send Crash Reports From 19241b9d9545afbe83bacdcba25a20dba5a16bb4 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:54:49 -0700 Subject: [PATCH 2/2] Tweak Settings --- .../java/org/cssnr/parking/MainActivity.kt | 2 +- .../cssnr/parking/data/HistoryRepository.kt | 2 +- .../cssnr/parking/data/SettingsRepository.kt | 33 +++++++++---------- .../parking/ui/screens/SettingsScreen.kt | 22 ++++++------- .../parking/ui/viewmodel/SettingsViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 22 ++++++------- 6 files changed, 41 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/cssnr/parking/MainActivity.kt b/app/src/main/java/org/cssnr/parking/MainActivity.kt index 8e65a30..d0ac37d 100644 --- a/app/src/main/java/org/cssnr/parking/MainActivity.kt +++ b/app/src/main/java/org/cssnr/parking/MainActivity.kt @@ -39,7 +39,7 @@ class MainActivity : ComponentActivity() { * * Fire-and-forget on purpose: pruning is housekeeping nobody waits on, and * a failure only leaves stale rows until the next launch, so it is logged - * and dropped. Indefinitely (null retention) skips the query entirely. + * and dropped. Disabled (null retention) skips the query entirely. */ private fun pruneExpiredHistory() { lifecycleScope.launch { diff --git a/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt b/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt index e3ace83..a1368f0 100644 --- a/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt +++ b/app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt @@ -26,7 +26,7 @@ class HistoryRepository(database: AppDatabase) { /** * Deletes every record older than [cutoff] (epoch millis) and returns how * many rows went away. The caller derives [cutoff] from the retention - * setting; null retention (Indefinitely) means don't call this at all. + * setting; null retention (Disabled) means don't call this at all. */ suspend fun prune(cutoff: Long): Int = historyDao.deleteOlderThan(cutoff) } \ No newline at end of file diff --git a/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt b/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt index f33f094..ad10609 100644 --- a/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt +++ b/app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt @@ -10,12 +10,12 @@ import kotlinx.coroutines.flow.map class SettingsRepository(private val context: Context) { val historyDuration: Flow = context.parkingDataStore.data.map { preferences -> - preferences[HISTORY_DURATION] ?: DEFAULT_HISTORY_DURATION + preferences[HISTORY] ?: DEFAULT_HISTORY } suspend fun setHistoryDuration(index: Int) { context.parkingDataStore.edit { preferences -> - preferences[HISTORY_DURATION] = index.coerceIn(0, HISTORY_DURATION_STEPS) + preferences[HISTORY] = index.coerceIn(0, HISTORY_STEPS) } } @@ -30,25 +30,24 @@ class SettingsRepository(private val context: Context) { } companion object { - // 0=1 week | 1=2 weeks | 2=1 month | 3=3 months | 4=6 months | 5=1 year | 6=Indefinitely - const val HISTORY_DURATION_STEPS = 6 - const val DEFAULT_HISTORY_DURATION = 3 - val HISTORY_DURATION = intPreferencesKey("history_duration") + // 0=Disabled | 1=1 week | 2=2 weeks | 3=1 month | 4=3 months | 5=6 months | 6=1 year + const val HISTORY_STEPS = 6 + const val DEFAULT_HISTORY = 4 + val HISTORY = intPreferencesKey("history") val CRASH_REPORTING = booleanPreferencesKey("acra.enable") /** - * Retention in millis for a history duration index, or null to keep - * everything (Indefinitely). Months are 30-day approximations: history - * pruning is housekeeping, not billing, so calendar exactness buys - * nothing here. + * Retention in millis for a history index, or null to keep everything + * (Disabled). Months are 30-day approximations: history pruning is + * housekeeping, not billing, so calendar exactness buys nothing here. */ - fun retentionMillis(index: Int): Long? = when (index.coerceIn(0, HISTORY_DURATION_STEPS)) { - 0 -> 7L * DAY_MILLIS - 1 -> 14L * DAY_MILLIS - 2 -> 30L * DAY_MILLIS - 3 -> 90L * DAY_MILLIS - 4 -> 180L * DAY_MILLIS - 5 -> 365L * DAY_MILLIS + fun retentionMillis(index: Int): Long? = when (index.coerceIn(0, HISTORY_STEPS)) { + 1 -> 7L * DAY_MILLIS + 2 -> 14L * DAY_MILLIS + 3 -> 30L * DAY_MILLIS + 4 -> 90L * DAY_MILLIS + 5 -> 180L * DAY_MILLIS + 6 -> 365L * DAY_MILLIS else -> null } diff --git a/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt b/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt index 1b24f62..35a205b 100644 --- a/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt @@ -113,21 +113,21 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()), ) { val historyLabels = listOf( - stringResource(R.string.history_duration_1_week), - stringResource(R.string.history_duration_2_weeks), - stringResource(R.string.history_duration_1_month), - stringResource(R.string.history_duration_3_months), - stringResource(R.string.history_duration_6_months), - stringResource(R.string.history_duration_1_year), - stringResource(R.string.history_duration_indefinitely), + stringResource(R.string.history_disabled), + stringResource(R.string.history_1_week), + stringResource(R.string.history_2_weeks), + stringResource(R.string.history_1_month), + stringResource(R.string.history_3_months), + stringResource(R.string.history_6_months), + stringResource(R.string.history_1_year), ) SettingsGroup( title = stringResource(R.string.settings_group_application), tiles = listOf( SettingsTile.Custom( icon = rememberVectorPainter(Icons.Filled.History), - title = stringResource(R.string.settings_history_duration), - summary = stringResource(R.string.settings_history_duration_summary), + title = stringResource(R.string.settings_history), + summary = stringResource(R.string.settings_history_summary), content = { HistoryDurationSlider( value = historyDuration, @@ -209,7 +209,7 @@ fun SettingsScreen( fun SettingsScreenPreview() { ParKingTheme { SettingsScreen( - historyDuration = 3, + historyDuration = 4, onHistoryDurationChange = {}, crashReporting = true, onCrashReportingChange = {}, @@ -220,7 +220,7 @@ fun SettingsScreenPreview() { /** * Discrete M3 slider with 7 stops: - * 1 week | 2 weeks | 1 month | 2 months | 6 months | 1 year | Indefinitely. + * Disabled | 1 week | 2 weeks | 1 month | 3 months | 6 months | 1 year. * * 7 stops on 0f..6f means 5 intermediate steps, which draws the notch/tick at * each stop. Persists only onValueChangeFinished to avoid DataStore writes diff --git a/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt index d759b73..b057a99 100644 --- a/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/cssnr/parking/ui/viewmodel/SettingsViewModel.kt @@ -17,7 +17,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application .stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, - initialValue = SettingsRepository.DEFAULT_HISTORY_DURATION, + initialValue = SettingsRepository.DEFAULT_HISTORY, ) fun setHistoryDuration(index: Int) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2636a4f..af1a278 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,19 +3,19 @@ Settings Application - History Duration - How long to keep history. - 1 week - 2 weeks - 1 month - 3 months - 6 months - 1 year - Indefinitely + Prune History + How long to keep history. + Disabled + 1 week + 2 weeks + 1 month + 3 months + 6 months + 1 year Debug Enable Crash Reporting - Send Crash Reports + Send crash reports. About @@ -28,7 +28,7 @@ Visit Website View Developer\'s Website Join Discord - Chat with us on Discord + Chat with Us on Discord Support Development Donate on Ko-fi Please Reconsider