diff --git a/donextv2/build.gradle.kts b/donextv2/build.gradle.kts index 52bca20..5a35fb6 100644 --- a/donextv2/build.gradle.kts +++ b/donextv2/build.gradle.kts @@ -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") diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/PreferencesModule.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/PreferencesModule.kt new file mode 100644 index 0000000..8df6014 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/PreferencesModule.kt @@ -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 = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(SETTINGS_PREFERENCES_NAME) } + ) +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt index 040d97c..428fbf3 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/injection/RepositoryModule.kt @@ -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 } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/preferences/SettingsRepositoryImpl.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/preferences/SettingsRepositoryImpl.kt new file mode 100644 index 0000000..243a5a3 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/data/local/preferences/SettingsRepositoryImpl.kt @@ -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 +) : 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") + } +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/SettingsRepository.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/SettingsRepository.kt new file mode 100644 index 0000000..9dc7527 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/repository/SettingsRepository.kt @@ -0,0 +1,6 @@ +package com.wismna.geoffroy.donext.domain.repository + +interface SettingsRepository { + suspend fun getLastOpenedTaskListId(): Long? + suspend fun setLastOpenedTaskListId(taskListId: Long) +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetLastOpenedTaskListIdUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetLastOpenedTaskListIdUseCase.kt new file mode 100644 index 0000000..7a5c352 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/GetLastOpenedTaskListIdUseCase.kt @@ -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() +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/SaveLastOpenedTaskListUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/SaveLastOpenedTaskListUseCase.kt new file mode 100644 index 0000000..f430316 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/SaveLastOpenedTaskListUseCase.kt @@ -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) +} diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt index 8490f2a..b1e9415 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt @@ -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() + .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 } } -} \ No newline at end of file +} diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/data/local/preferences/SettingsRepositoryImplTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/data/local/preferences/SettingsRepositoryImplTest.kt new file mode 100644 index 0000000..a2ccc78 --- /dev/null +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/data/local/preferences/SettingsRepositoryImplTest.kt @@ -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) + } +} diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt index 4c25fd2..29815a9 100644 --- a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt +++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt @@ -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> 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 { + 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 { + 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 { + 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()) } + } }