diff --git a/app/src/main/java/org/cssnr/parking/MainActivity.kt b/app/src/main/java/org/cssnr/parking/MainActivity.kt index 4c802cb..d0ac37d 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. Disabled (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..a1368f0 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 (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 280012d..ad10609 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] ?: DEFAULT_HISTORY + } + + suspend fun setHistoryDuration(index: Int) { + context.parkingDataStore.edit { preferences -> + preferences[HISTORY] = index.coerceIn(0, HISTORY_STEPS) + } + } + val crashReporting: Flow = context.parkingDataStore.data.map { preferences -> preferences[CRASH_REPORTING] ?: true } @@ -18,7 +29,28 @@ class SettingsRepository(private val context: Context) { } } - private companion object { + companion object { + // 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 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_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 + } + + 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..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 @@ -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_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), + summary = stringResource(R.string.settings_history_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 = 4, + onHistoryDurationChange = {}, crashReporting = true, onCrashReportingChange = {}, onCrashReportingMoreInfo = {}, ) } +} + +/** + * Discrete M3 slider with 7 stops: + * 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 + * 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..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 @@ -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, + ) + + 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..af1a278 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,9 +2,20 @@ ParKing Settings + Application + 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 @@ -17,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