feat(settings): Add option to reset all stats

This commit introduces a feature allowing users to delete all their statistics from the app.

Changes include:
- A new "Reset data" dialog to confirm the action.
- A "Delete data ?" button in the settings screen to trigger the dialog.
- Backend logic to clear all statistics from the database.
- Fixes a crash on the stats screen when there is no data to display.
- Added new strings and translations for the reset data feature.
This commit is contained in:
Adrien BOISSIER
2025-12-09 23:19:46 +01:00
parent 7a83b39b49
commit 24a0b71427
11 changed files with 215 additions and 14 deletions

View File

@@ -60,4 +60,7 @@ interface StatDao {
@Query("SELECT date FROM stat ORDER BY date DESC LIMIT 1") @Query("SELECT date FROM stat ORDER BY date DESC LIMIT 1")
suspend fun getLastDate(): LocalDate? suspend fun getLastDate(): LocalDate?
@Query("DELETE FROM stat")
suspend fun clearAll()
} }

View File

@@ -33,6 +33,8 @@ interface StatRepository {
fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatFocusTime?> fun getLastNDaysAverageFocusTimes(n: Int): Flow<StatFocusTime?>
suspend fun getLastDate(): LocalDate? suspend fun getLastDate(): LocalDate?
suspend fun deleteAllStats()
} }
/** /**
@@ -108,4 +110,8 @@ class AppStatRepository(
statDao.getLastNDaysAvgFocusTimes(n) statDao.getLastNDaysAvgFocusTimes(n)
override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() override suspend fun getLastDate(): LocalDate? = statDao.getLastDate()
override suspend fun deleteAllStats() =
statDao.clearAll()
} }

View File

@@ -0,0 +1,107 @@
package org.nsh07.pomodoro.ui.settingsScreen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.TomatoTheme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ResetDataDialog(
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
resetData: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
) {
Card(
modifier = modifier
.clickable(onClick = onDismiss),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.padding(24.dp)) {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(40.dp)
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.reset_data),
textAlign = TextAlign.Center,
style = typography.headlineSmall,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.reset_data_dialog_text),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = resetData,
shapes = ButtonDefaults.shapes(),
) {
Text(stringResource(android.R.string.ok))
}
}
}
}
}
}
@Preview
@Composable
fun PreviewResetDataDialog() {
TomatoTheme {
Surface {
ResetDataDialog(
onDismiss = { },
resetData = { }
)
}
}
}

View File

@@ -27,9 +27,11 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -42,6 +44,7 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -170,6 +173,14 @@ private fun SettingsScreen(
setShowSheet = { showLocaleSheet = it } setShowSheet = { showLocaleSheet = it }
) )
if(settingsState.isShowingEraseDataDialog){
ResetDataDialog(resetData = {
onAction(SettingsAction.EraseData)
}, onDismiss = {
onAction(SettingsAction.CancelEraseData)
})
}
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = backStack::removeLastOrNull, onBack = backStack::removeLastOrNull,
@@ -294,6 +305,20 @@ private fun SettingsScreen(
} }
item { Spacer(Modifier.height(12.dp)) } item { Spacer(Modifier.height(12.dp)) }
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
){
TextButton(
onClick = { onAction(SettingsAction.AskEraseData) },
) {
Text(stringResource(R.string.reset_data))
}
}
}
} }
} }
} }

View File

@@ -33,4 +33,7 @@ sealed interface SettingsAction {
data class SaveAlarmSound(val uri: Uri?) : SettingsAction data class SaveAlarmSound(val uri: Uri?) : SettingsAction
data class SaveTheme(val theme: String) : SettingsAction data class SaveTheme(val theme: String) : SettingsAction
data class SaveColorScheme(val color: Color) : SettingsAction data class SaveColorScheme(val color: Color) : SettingsAction
data object AskEraseData : SettingsAction
data object CancelEraseData : SettingsAction
data object EraseData : SettingsAction
} }

View File

@@ -35,6 +35,7 @@ data class SettingsState(
val singleProgressBar: Boolean = false, val singleProgressBar: Boolean = false,
val autostartNextSession: Boolean = false, val autostartNextSession: Boolean = false,
val secureAod: Boolean = true, val secureAod: Boolean = true,
val isShowingEraseDataDialog: Boolean = false,
val focusTime: Long = 25 * 60 * 1000L, val focusTime: Long = 25 * 60 * 1000L,
val shortBreakTime: Long = 5 * 60 * 1000L, val shortBreakTime: Long = 5 * 60 * 1000L,

View File

@@ -47,6 +47,7 @@ import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.billing.BillingManager import org.nsh07.pomodoro.billing.BillingManager
import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.PreferenceRepository
import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.data.StateRepository import org.nsh07.pomodoro.data.StateRepository
import org.nsh07.pomodoro.service.ServiceHelper import org.nsh07.pomodoro.service.ServiceHelper
import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.Screen
@@ -59,6 +60,7 @@ class SettingsViewModel(
private val billingManager: BillingManager, private val billingManager: BillingManager,
private val preferenceRepository: PreferenceRepository, private val preferenceRepository: PreferenceRepository,
private val stateRepository: StateRepository, private val stateRepository: StateRepository,
private val statRepository: StatRepository,
private val serviceHelper: ServiceHelper, private val serviceHelper: ServiceHelper,
private val time: MutableStateFlow<Long> private val time: MutableStateFlow<Long>
) : ViewModel() { ) : ViewModel() {
@@ -120,6 +122,25 @@ class SettingsViewModel(
is SettingsAction.SaveTheme -> saveTheme(action.theme) is SettingsAction.SaveTheme -> saveTheme(action.theme)
is SettingsAction.SaveBlackTheme -> saveBlackTheme(action.enabled) is SettingsAction.SaveBlackTheme -> saveBlackTheme(action.enabled)
is SettingsAction.SaveAodEnabled -> saveAodEnabled(action.enabled) is SettingsAction.SaveAodEnabled -> saveAodEnabled(action.enabled)
is SettingsAction.AskEraseData -> askEraseData()
is SettingsAction.CancelEraseData -> cancelEraseData()
is SettingsAction.EraseData -> deleteStats()
}
}
private fun cancelEraseData() {
viewModelScope.launch(Dispatchers.IO) {
_settingsState.update { currentState ->
currentState.copy(isShowingEraseDataDialog = false)
}
}
}
private fun askEraseData() {
viewModelScope.launch(Dispatchers.IO) {
_settingsState.update { currentState ->
currentState.copy(isShowingEraseDataDialog = true)
}
} }
} }
@@ -137,6 +158,20 @@ class SettingsViewModel(
} }
} }
private fun deleteStats() {
viewModelScope.launch(Dispatchers.IO) {
serviceHelper.startService(TimerAction.ResetTimer)
focusFlowCollectionJob?.cancel()
shortBreakFlowCollectionJob?.cancel()
longBreakFlowCollectionJob?.cancel()
statRepository.deleteAllStats()
_settingsState.update {
it.copy(isShowingEraseDataDialog = false)
}
}
}
fun runTextFieldFlowCollection() { fun runTextFieldFlowCollection() {
focusFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { focusFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { focusTimeTextFieldState.text } snapshotFlow { focusTimeTextFieldState.text }
@@ -343,13 +378,13 @@ class SettingsViewModel(
) )
val alarmSoundUri = ( val alarmSoundUri = (
preferenceRepository.getStringPreference("alarm_sound") preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference( ?: preferenceRepository.saveStringPreference(
"alarm_sound", "alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI (Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString() ?: Settings.System.DEFAULT_RINGTONE_URI).toString()
) )
).toUri() ).toUri()
val theme = preferenceRepository.getStringPreference("theme") val theme = preferenceRepository.getStringPreference("theme")
?: preferenceRepository.saveStringPreference("theme", settingsState.theme) ?: preferenceRepository.saveStringPreference("theme", settingsState.theme)
@@ -458,6 +493,7 @@ class SettingsViewModel(
val appPreferenceRepository = application.container.appPreferenceRepository val appPreferenceRepository = application.container.appPreferenceRepository
val serviceHelper = application.container.serviceHelper val serviceHelper = application.container.serviceHelper
val stateRepository = application.container.stateRepository val stateRepository = application.container.stateRepository
val statRepository = application.container.appStatRepository
val time = application.container.time val time = application.container.time
SettingsViewModel( SettingsViewModel(
@@ -465,7 +501,8 @@ class SettingsViewModel(
preferenceRepository = appPreferenceRepository, preferenceRepository = appPreferenceRepository,
serviceHelper = serviceHelper, serviceHelper = serviceHelper,
stateRepository = stateRepository, stateRepository = stateRepository,
time = time statRepository = statRepository,
time = time,
) )
} }
} }

View File

@@ -143,17 +143,21 @@ fun StatsScreen(
val lastMonthSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() } val lastMonthSummaryAnalysisModelProducer = remember { CartesianChartModelProducer() }
LaunchedEffect(lastWeekAverageFocusTimes) { LaunchedEffect(lastWeekAverageFocusTimes) {
lastWeekSummaryAnalysisModelProducer.runTransaction { if (lastWeekAverageFocusTimes.isNotEmpty()) {
columnSeries { lastWeekSummaryAnalysisModelProducer.runTransaction {
series(lastWeekAverageFocusTimes) columnSeries {
series(lastWeekAverageFocusTimes)
}
} }
} }
} }
LaunchedEffect(lastMonthAverageFocusTimes) { LaunchedEffect(lastMonthAverageFocusTimes) {
lastMonthSummaryAnalysisModelProducer.runTransaction { if (lastMonthAverageFocusTimes.isNotEmpty()) {
columnSeries { lastMonthSummaryAnalysisModelProducer.runTransaction {
series(lastMonthAverageFocusTimes) columnSeries {
series(lastMonthAverageFocusTimes)
}
} }
} }
} }

View File

@@ -30,6 +30,7 @@ import com.patrykandpatrick.vico.core.common.data.ExtraStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -42,6 +43,7 @@ import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.TextStyle import java.time.format.TextStyle
import java.util.Locale import java.util.Locale
import kotlin.collections.isNotEmpty
class StatsViewModel( class StatsViewModel(
private val statRepository: StatRepository private val statRepository: StatRepository
@@ -66,6 +68,9 @@ class StatsViewModel(
val lastWeekSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastWeekSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStatsSummary(7) statRepository.getLastNDaysStatsSummary(7)
.filter {list ->
list.isNotEmpty()
}
.map { list -> .map { list ->
// reversing is required because we need ascending order while the DB returns descending order // reversing is required because we need ascending order while the DB returns descending order
val reversed = list.reversed() val reversed = list.reversed()
@@ -108,6 +113,9 @@ class StatsViewModel(
val lastMonthSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastMonthSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStatsSummary(30) statRepository.getLastNDaysStatsSummary(30)
.filter {list ->
list.isNotEmpty()
}
.map { list -> .map { list ->
val reversed = list.reversed() val reversed = list.reversed()
val keys = reversed.map { it.date.dayOfMonth.toString() } val keys = reversed.map { it.date.dayOfMonth.toString() }
@@ -144,6 +152,9 @@ class StatsViewModel(
val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> = val lastYearSummaryChartData: StateFlow<Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>> =
statRepository.getLastNDaysStatsSummary(365) statRepository.getLastNDaysStatsSummary(365)
.filter {list ->
list.isNotEmpty()
}
.map { list -> .map { list ->
val reversed = list.reversed() val reversed = list.reversed()
val keys = reversed.map { it.date.format(yearDayFormatter) } val keys = reversed.map { it.date.format(yearDayFormatter) }

View File

@@ -86,4 +86,6 @@
<string name="session_only_progress">Progression de la session uniquement</string> <string name="session_only_progress">Progression de la session uniquement</string>
<string name="session_only_progress_desc">Montre uniquement la progression de la session actuelle, au lieu de la séquence entière.</string> <string name="session_only_progress_desc">Montre uniquement la progression de la session actuelle, au lieu de la séquence entière.</string>
<string name="media_volume_for_alarm_desc">Fonctionne uniquement avec un casque. Si le casque est déconnecté, l\'alarme retentit via le haut-parleur au volume des médias.</string> <string name="media_volume_for_alarm_desc">Fonctionne uniquement avec un casque. Si le casque est déconnecté, l\'alarme retentit via le haut-parleur au volume des médias.</string>
<string name="reset_data">Réinitialiser les données</string>
<string name="reset_data_dialog_text">Êtes-vous sûr de vouloir réinitialiser toutes vos données ?</string>
</resources> </resources>

View File

@@ -113,4 +113,6 @@
<string name="secure_aod_desc">Automatically lock your device after a timeout, while keeping the AOD visible</string> <string name="secure_aod_desc">Automatically lock your device after a timeout, while keeping the AOD visible</string>
<string name="timer_reset_message">Timer reset</string> <string name="timer_reset_message">Timer reset</string>
<string name="undo">Undo</string> <string name="undo">Undo</string>
<string name="reset_data">Reset data</string>
<string name="reset_data_dialog_text">Are you sure you want to Reset all your data ?</string>
</resources> </resources>