Create preference data store

Save currently selected list
This commit is contained in:
Geoffroy Bonneville
2026-07-16 17:28:04 -04:00
parent 150058437f
commit 210b509132
10 changed files with 272 additions and 15 deletions
+1
View File
@@ -91,6 +91,7 @@ dependencies {
implementation("androidx.navigation:navigation-compose:2.9.8")
implementation("androidx.hilt:hilt-navigation-compose:1.4.0")
implementation("sh.calvin.reorderable:reorderable:3.1.0")
implementation("androidx.datastore:datastore-preferences:1.2.1")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.14.11")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
@@ -0,0 +1,28 @@
package com.wismna.geoffroy.donext.data.injection
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PreferencesModule {
private const val SETTINGS_PREFERENCES_NAME = "settings"
@Provides
@Singleton
fun provideSettingsDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile(SETTINGS_PREFERENCES_NAME) }
)
}
@@ -1,7 +1,9 @@
package com.wismna.geoffroy.donext.data.injection
import com.wismna.geoffroy.donext.data.local.preferences.SettingsRepositoryImpl
import com.wismna.geoffroy.donext.data.local.repository.TaskListRepositoryImpl
import com.wismna.geoffroy.donext.data.local.repository.TaskRepositoryImpl
import com.wismna.geoffroy.donext.domain.repository.SettingsRepository
import com.wismna.geoffroy.donext.domain.repository.TaskListRepository
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import dagger.Binds
@@ -22,4 +24,9 @@ abstract class RepositoryModule {
abstract fun bindTaskListRepository(
impl: TaskListRepositoryImpl
): TaskListRepository
@Binds
abstract fun bindSettingsRepository(
impl: SettingsRepositoryImpl
): SettingsRepository
}
@@ -0,0 +1,29 @@
package com.wismna.geoffroy.donext.data.local.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.longPreferencesKey
import com.wismna.geoffroy.donext.domain.repository.SettingsRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import java.io.IOException
import javax.inject.Inject
class SettingsRepositoryImpl @Inject constructor(
private val dataStore: DataStore<Preferences>
) : SettingsRepository {
override suspend fun getLastOpenedTaskListId(): Long? =
dataStore.data
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
.first()[LAST_OPENED_TASK_LIST_ID_KEY]
override suspend fun setLastOpenedTaskListId(taskListId: Long) {
dataStore.edit { prefs -> prefs[LAST_OPENED_TASK_LIST_ID_KEY] = taskListId }
}
private companion object {
val LAST_OPENED_TASK_LIST_ID_KEY = longPreferencesKey("last_opened_task_list_id")
}
}
@@ -0,0 +1,6 @@
package com.wismna.geoffroy.donext.domain.repository
interface SettingsRepository {
suspend fun getLastOpenedTaskListId(): Long?
suspend fun setLastOpenedTaskListId(taskListId: Long)
}
@@ -0,0 +1,10 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.SettingsRepository
import javax.inject.Inject
class GetLastOpenedTaskListIdUseCase @Inject constructor(
private val repository: SettingsRepository
) {
suspend operator fun invoke(): Long? = repository.getLastOpenedTaskListId()
}
@@ -0,0 +1,10 @@
package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.repository.SettingsRepository
import javax.inject.Inject
class SaveLastOpenedTaskListUseCase @Inject constructor(
private val repository: SettingsRepository
) {
suspend operator fun invoke(taskListId: Long) = repository.setLastOpenedTaskListId(taskListId)
}
@@ -7,18 +7,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.usecase.GetLastOpenedTaskListIdUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.SaveLastOpenedTaskListUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
getTaskListsUseCase: GetTaskListsUseCase,
private val getLastOpenedTaskListIdUseCase: GetLastOpenedTaskListIdUseCase,
private val saveLastOpenedTaskListUseCase: SaveLastOpenedTaskListUseCase,
val uiEventBus: UiEventBus
) : ViewModel() {
@@ -38,8 +40,9 @@ class MainViewModel @Inject constructor(
var showAddListSheet by mutableStateOf(false)
init {
getTaskListsUseCase()
.onEach { lists ->
viewModelScope.launch {
val lastOpenedTaskListId = getLastOpenedTaskListIdUseCase()
getTaskListsUseCase().collect { lists ->
destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name)
} +
@@ -47,11 +50,14 @@ class MainViewModel @Inject constructor(
AppDestination.RecycleBin +
AppDestination.DueTodayList
if (startDestination == AppDestination.ManageLists && destinations.isNotEmpty()) {
startDestination = destinations.first()
startDestination = destinations
.filterIsInstance<AppDestination.TaskList>()
.firstOrNull { it.taskListId == lastOpenedTaskListId }
?: destinations.first()
}
isLoading = false
}
.launchIn(viewModelScope)
}
}
fun navigateBack() {
@@ -79,12 +85,21 @@ class MainViewModel @Inject constructor(
val route = navBackStackEntry?.destination?.route
val taskListId = navBackStackEntry?.arguments?.getLong("taskListId")
currentDestination = destinations.firstOrNull { dest ->
val resolved = destinations.firstOrNull { dest ->
when (dest) {
is AppDestination.TaskList -> taskListId != null && dest.taskListId == taskListId
else -> dest.route == route
}
} ?: startDestination
if (resolved != currentDestination) {
currentDestination = resolved
(resolved as? AppDestination.TaskList)?.let { taskList ->
viewModelScope.launch {
saveLastOpenedTaskListUseCase(taskList.taskListId)
}
}
}
}
fun doesListExist(taskListId: Long): Boolean {
@@ -92,4 +107,4 @@ class MainViewModel @Inject constructor(
dest is AppDestination.TaskList && dest.taskListId == taskListId
}
}
}
}
@@ -0,0 +1,43 @@
package com.wismna.geoffroy.donext.data.local.preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsRepositoryImplTest {
@get:Rule
val tempFolder = TemporaryFolder()
private fun createRepository(): SettingsRepositoryImpl {
// DataStore's internal actor coroutine runs on this scope. A plain TestScope() uses
// StandardTestDispatcher, which queues work that only runs when its own scheduler is
// advanced — but nothing here ever advances it, so reads/writes deadlock forever.
// UnconfinedTestDispatcher runs dispatched coroutines eagerly instead, so the actor
// makes progress without needing to be pumped.
val testDataStore = PreferenceDataStoreFactory.create(
scope = TestScope(UnconfinedTestDispatcher()),
produceFile = { tempFolder.newFile("test.preferences_pb") }
)
return SettingsRepositoryImpl(testDataStore)
}
@Test
fun `getLastOpenedTaskListId returns null when nothing saved`() = runTest {
assertThat(createRepository().getLastOpenedTaskListId()).isNull()
}
@Test
fun `setLastOpenedTaskListId then get returns saved value`() = runTest {
val repo = createRepository()
repo.setLastOpenedTaskListId(42L)
assertThat(repo.getLastOpenedTaskListId()).isEqualTo(42L)
}
}
@@ -4,7 +4,9 @@ import androidx.navigation.NavBackStackEntry
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.GetLastOpenedTaskListIdUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.SaveLastOpenedTaskListUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.*
@@ -23,8 +25,17 @@ class MainViewModelTest {
private val uiEventBus: UiEventBus = mockk(relaxUnitFun = true)
private lateinit var getTaskListsFlow: MutableSharedFlow<List<TaskList>>
private lateinit var getTaskListsUseCase: GetTaskListsUseCase
private lateinit var getLastOpenedTaskListIdUseCase: GetLastOpenedTaskListIdUseCase
private val saveLastOpenedTaskListUseCase: SaveLastOpenedTaskListUseCase = mockk(relaxUnitFun = true)
private lateinit var viewModel: MainViewModel
private fun createViewModel() = MainViewModel(
getTaskListsUseCase,
getLastOpenedTaskListIdUseCase,
saveLastOpenedTaskListUseCase,
uiEventBus
)
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
@@ -32,6 +43,9 @@ class MainViewModelTest {
getTaskListsUseCase = mockk {
every { this@mockk.invoke() } returns getTaskListsFlow
}
getLastOpenedTaskListIdUseCase = mockk {
coEvery { this@mockk.invoke() } returns null
}
}
@After
@@ -41,14 +55,14 @@ class MainViewModelTest {
@Test
fun `initially isLoading is true and destinations are empty`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
assertThat(viewModel.isLoading).isTrue()
assertThat(viewModel.destinations).isEmpty()
}
@Test
fun `when task lists are emitted they populate destinations and isLoading becomes false`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
advanceUntilIdle()
val lists = listOf(
@@ -72,9 +86,45 @@ class MainViewModelTest {
assertThat(viewModel.isLoading).isFalse()
}
@Test
fun `starts on the last opened list when it still exists`() = runTest {
getLastOpenedTaskListIdUseCase = mockk {
coEvery { this@mockk.invoke() } returns 2L
}
viewModel = createViewModel()
advanceUntilIdle()
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Personal", isDeleted = false, order = 1)
)
getTaskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.startDestination).isEqualTo(AppDestination.TaskList(2L, "Personal"))
}
@Test
fun `falls back to the first list when the last opened list no longer exists`() = runTest {
getLastOpenedTaskListIdUseCase = mockk {
coEvery { this@mockk.invoke() } returns 99L
}
viewModel = createViewModel()
advanceUntilIdle()
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Personal", isDeleted = false, order = 1)
)
getTaskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.startDestination).isEqualTo(AppDestination.TaskList(1L, "Work"))
}
@Test
fun `navigateBack sends UiEvent_NavigateBack`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
viewModel.navigateBack()
advanceUntilIdle()
@@ -84,7 +134,7 @@ class MainViewModelTest {
@Test
fun `onNewTaskButtonClicked sets showTaskSheet true and sends CreateNewTask`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
val taskListId = 42L
viewModel.onNewTaskButtonClicked(taskListId)
@@ -96,7 +146,7 @@ class MainViewModelTest {
@Test
fun `onDismissTaskSheet sets showTaskSheet false and clears sticky`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
viewModel.showTaskSheet = true
viewModel.onDismissTaskSheet()
@@ -110,7 +160,7 @@ class MainViewModelTest {
@Test
fun `doesListExist returns true when taskListId is present`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
@@ -122,7 +172,7 @@ class MainViewModelTest {
@Test
fun `setCurrentDestination sets currentDestination based on navBackStackEntry`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel = createViewModel()
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
@@ -135,4 +185,62 @@ class MainViewModelTest {
viewModel.setCurrentDestination(entry)
assertThat(viewModel.currentDestination).isEqualTo(AppDestination.TaskList(1L, "Work"))
}
@Test
fun `setCurrentDestination saves the new list when navigating to a different TaskList`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Personal", isDeleted = false, order = 1)
)
viewModel = createViewModel()
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
val entry = mockk<NavBackStackEntry> {
every { destination.route } returns AppDestination.TaskList(2L, "Personal").route
every { arguments?.getLong("taskListId") } returns 2L
}
viewModel.setCurrentDestination(entry)
advanceUntilIdle()
coVerify { saveLastOpenedTaskListUseCase(2L) }
}
@Test
fun `setCurrentDestination does not save when navigating to a non-list destination`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
val entry = mockk<NavBackStackEntry> {
every { destination.route } returns AppDestination.RecycleBin.route
every { arguments } returns null
}
viewModel.setCurrentDestination(entry)
advanceUntilIdle()
coVerify(exactly = 0) { saveLastOpenedTaskListUseCase(any()) }
}
@Test
fun `setCurrentDestination only saves once for repeated identical navigation`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = createViewModel()
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
val entry = mockk<NavBackStackEntry> {
every { destination.route } returns AppDestination.TaskList(1L, "Work").route
every { arguments?.getLong("taskListId") } returns 1L
}
viewModel.setCurrentDestination(entry)
viewModel.setCurrentDestination(entry)
advanceUntilIdle()
coVerify(exactly = 1) { saveLastOpenedTaskListUseCase(any()) }
}
}