Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/src/main/java/org/cssnr/parking/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,13 +10,20 @@ 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

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pruneExpiredHistory()
enableEdgeToEdge()
setContent {
ParKingTheme {
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/cssnr/parking/data/HistoryRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
34 changes: 33 additions & 1 deletion app/src/main/java/org/cssnr/parking/data/SettingsRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> = 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<Boolean> = context.parkingDataStore.data.map { preferences ->
preferences[CRASH_REPORTING] ?: true
}
Expand All @@ -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
}
}
3 changes: 3 additions & 0 deletions app/src/main/java/org/cssnr/parking/data/db/HistoryDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
79 changes: 79 additions & 0 deletions app/src/main/java/org/cssnr/parking/ui/screens/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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 = {
Expand All @@ -70,6 +80,8 @@ fun SettingsRoute(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
historyDuration: Int,
onHistoryDurationChange: (Int) -> Unit,
crashReporting: Boolean,
onCrashReportingChange: (Boolean) -> Unit,
onCrashReportingMoreInfo: () -> Unit,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<String>,
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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application

private val settingsRepository = SettingsRepository(application)

val historyDuration: StateFlow<Int> = settingsRepository.historyDuration
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = SettingsRepository.DEFAULT_HISTORY,
)

fun setHistoryDuration(index: Int) {
viewModelScope.launch {
settingsRepository.setHistoryDuration(index)
}
}

val crashReporting: StateFlow<Boolean> = settingsRepository.crashReporting
.stateIn(
scope = viewModelScope,
Expand Down
15 changes: 13 additions & 2 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@
<string name="app_name">ParKing</string>
<string name="settings">Settings</string>

<string name="settings_group_application">Application</string>
<string name="settings_history">Prune History</string>
<string name="settings_history_summary">How long to keep history.</string>
<string name="history_disabled">Disabled</string>
<string name="history_1_week">1 week</string>
<string name="history_2_weeks">2 weeks</string>
<string name="history_1_month">1 month</string>
<string name="history_3_months">3 months</string>
<string name="history_6_months">6 months</string>
<string name="history_1_year">1 year</string>

<string name="settings_group_debug">Debug</string>
<string name="settings_crash_reporting">Enable Crash Reporting</string>
<string name="settings_crash_reporting_summary">Send Crash Reports</string>
<string name="settings_crash_reporting_summary">Send crash reports.</string>

<!-- About -->
<string name="settings_group_about">About</string>
Expand All @@ -17,7 +28,7 @@
<string name="about_visit_website">Visit Website</string>
<string name="about_visit_website_summary">View Developer\'s Website</string>
<string name="about_discord">Join Discord</string>
<string name="about_discord_summary">Chat with us on Discord</string>
<string name="about_discord_summary">Chat with Us on Discord</string>
<string name="about_support_development">Support Development</string>
<string name="about_support_development_summary">Donate on Ko-fi</string>
<string name="acra_disable_title">Please Reconsider</string>
Expand Down
Loading