mirror of
https://github.com/wismna/DoNext.git
synced 2026-07-25 12:13:04 -04:00
Create preference data store
Save currently selected list
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+29
@@ -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")
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.wismna.geoffroy.donext.domain.repository
|
||||
|
||||
interface SettingsRepository {
|
||||
suspend fun getLastOpenedTaskListId(): Long?
|
||||
suspend fun setLastOpenedTaskListId(taskListId: Long)
|
||||
}
|
||||
+10
@@ -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()
|
||||
}
|
||||
+10
@@ -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)
|
||||
}
|
||||
+23
-8
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+115
-7
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user